--- eip: 8351 title: Prediction Market Conditional Tokens wrapper description: Factory and ERC-20 wrapper for Conditional Tokens positions, exposing position parameters and complete-set operations author: Martchelo (@martchelo-eth) discussions-to: https://ethereum-magicians.org/t/erc-8351-prediction-market-ctf-wrapper/29106 status: Draft type: Standards Track category: ERC created: 2026-07-25 requires: 20, 1155 --- ## Abstract This proposal standardizes the representation of Conditional Tokens Framework (CTF) positions — [ERC-1155](./eip-1155.md) prediction-market outcome shares — as [ERC-20](./eip-20.md) tokens. Two interfaces are defined. A contract conforming to `ICTFWrapper` is an ERC-20 token that wraps a single CTF position one-to-one, exposing `wrap`, `unwrap`, and a pointer to its factory. A contract conforming to `ICTFWrapperFactory` is bound to one Conditional Tokens contract and one collateral token; it deploys and registers wrappers, records each wrapper's position parameters, and exposes the complete-set operations `split` and `merge`. ## Motivation Prediction-market outcome shares are predominantly represented as ERC-1155 tokens. Major venues issue positions through Gnosis' Conditional Tokens Framework, where each outcome is an ERC-1155 `positionId`. On-chain activity on these positions is growing: a widening set of DeFi protocols — lending markets, vaults, structured-product and leverage systems — want to use prediction-market shares as first-class assets. Those venues speak ERC-20, and almost none of them speak ERC-1155 position ids. The wrappers that exist today sit at two extremes. Generic wrappers (e.g., Gnosis' `Wrapped1155Factory`) expose only the position id, which by itself reveals nothing about which market the position belongs to, which outcome it represents, or what collateral redeems it. They also offer no way to split collateral into a complete set or merge a set back into collateral — an integrator must drive the Conditional Tokens contract directly and re-derive every id itself. Protocol-native per-outcome ERC-20s carry all of those parameters and that logic, but only through one protocol's bespoke types, getters, and market contracts, so an integration written against one venue transfers to no other. Between the two there is no common schema that gives builders the readable position parameters and the complete-set operations they need over a CTF-based venue. ## 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. ### Definitions - **Factory**: a contract conforming to `ICTFWrapperFactory`. - **Wrapper**: a contract conforming to `ICTFWrapper`, deployed and registered by a factory. - **`CTF`**: the Conditional Tokens contract returned by a factory's `conditionalTokens()`. - **`COLLATERAL`**: the token returned by a factory's `collateralToken()`. - **Position id**: the ERC-1155 id of the tuple `(parentCollectionId, conditionId, indexSet)` under `CTF` and `COLLATERAL`, computed as: ``` positionId = CTF.getPositionId( COLLATERAL, CTF.getCollectionId(parentCollectionId, conditionId, indexSet) ) ``` - **`positionId`**: within the wrapper interface, the position id recorded for that wrapper by its factory. ### Wrapper Interface Every conforming wrapper MUST implement [ERC-20](./eip-20.md), the [ERC-1155](./eip-1155.md) receiver interface, and the following interface: ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.0; interface ICTFWrapper /* is IERC20, IERC1155Receiver */ { event Wrapped(address indexed from, address indexed to, uint256 amount); event Unwrapped(address indexed from, address indexed to, uint256 amount); function factory() external view returns (address); function wrap(address to, uint256 amount) external; function unwrap(address to, uint256 amount) external; } ``` #### Wrapper Invariants The following MUST hold for every conforming wrapper. - **Single position.** A wrapper MUST wrap exactly one position id. That id MUST be fixed before the wrapper is exposed and MUST NOT change. - **Supply.** After every state-changing call, `totalSupply()` MUST equal `IERC1155(CTF).balanceOf(address(this), positionId)`. A wrapper MUST NOT hold a balance of that id that is not represented one-to-one by its ERC-20 supply. - **One-to-one, no fee.** `wrap` and `unwrap` MUST mint and burn exactly one ERC-20 unit per unit of the underlying position, and MUST NOT charge a fee or apply a scaling factor. #### Wrapper Metadata - `decimals()` is presentational only — it does not affect any amount in this specification — and MUST equal the `decimals()` of `COLLATERAL`. Because wrapping and splitting are both one-to-one, one ERC-20 unit of a wrapper is one unit of `COLLATERAL` at settlement, and a mismatch would misprice the wrapper everywhere it is quoted. - `name()` and `symbol()` SHOULD be derived deterministically from `positionId` and SHOULD NOT be mutable. #### Wrapper Methods ##### `factory` The factory that governs this wrapper and holds its parameters. - MUST return a contract conforming to `ICTFWrapperFactory` under which this wrapper is registered — `ICTFWrapperFactory(factory()).isWrapper(address(this))` MUST be `true`. - MUST NOT change. ```solidity function factory() external view returns (address); ``` ##### `wrap` Takes the underlying position from the caller and mints ERC-20 against it. - MUST transfer `amount` of `positionId` from `msg.sender` to the wrapper. - MUST mint `amount` of ERC-20 to `to`. - The invocation of the wrapper's own `onERC1155Received` caused by that inbound transfer MUST NOT mint. - MUST revert if `amount` is zero. - MUST revert if `to` is the zero address. - MUST NOT be gated on the settlement state of the condition. ```solidity function wrap(address to, uint256 amount) external; ``` ##### `unwrap` Burns ERC-20 from the caller and returns the underlying position. - MUST burn `amount` of ERC-20 from `msg.sender`. - MUST transfer `amount` of `positionId` to `to`. - MUST emit the `Unwrapped` event. - MUST revert if `amount` is zero. - MUST revert if `to` is the zero address. - MUST NOT be gated on the settlement state of the condition. ```solidity function unwrap(address to, uint256 amount) external; ``` ##### `onERC1155Received` Makes a direct transfer of the underlying position into the wrapper equivalent to `wrap`. - MUST revert if `msg.sender` is not `CTF`. - MUST revert if `id` is not `positionId`. - MUST NOT mint if the transfer originates from this wrapper's own `wrap`, which mints itself. ```solidity function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data) external returns (bytes4); ``` ##### `onERC1155BatchReceived` - MUST revert. ```solidity function onERC1155BatchReceived( address operator, address from, uint256[] calldata ids, uint256[] calldata values, bytes calldata data ) external returns (bytes4); ``` #### Wrapper Events ##### `Wrapped` MUST be emitted exactly once per wrap, whether through `wrap` or through a direct ERC-1155 transfer accepted by `onERC1155Received`. - `from`: the account the underlying position came from. - `to`: the account the ERC-20 was minted to. - `amount`: the number of units wrapped. ```solidity event Wrapped(address indexed from, address indexed to, uint256 amount); ``` ##### `Unwrapped` MUST be emitted when ERC-20 is unwrapped back into the underlying position. - `from`: the account the ERC-20 was burned from. - `to`: the account the underlying position was transferred to. - `amount`: the number of units unwrapped. ```solidity event Unwrapped(address indexed from, address indexed to, uint256 amount); ``` ### Factory Interface Every conforming factory MUST implement the following interface: ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.0; interface ICTFWrapperFactory { struct TokenParams { bytes32 conditionId; bytes32 parentCollectionId; uint256 indexSet; uint256 positionId; } event WrapperCreated( address indexed wrapper, uint256 indexed positionId, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 indexSet ); event Split( address indexed account, address indexed to, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 amountIn, address tokenIn ); event Merged( address indexed account, address indexed to, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 amountIn, address tokenOut, uint256 amountOut ); function conditionalTokens() external view returns (address); function collateralToken() external view returns (address); function wrapperOf(uint256 positionId) external view returns (address); function paramsOf(address wrapper) external view returns (TokenParams memory); function isWrapper(address wrapper) external view returns (bool); function getAddress(uint256 positionId) external view returns (address); function siblingWrapperOf(address wrapper, uint256 siblingIndexSet) external view returns (address); function deploy(bytes32 conditionId, bytes32 parentCollectionId, uint256 indexSet) external returns (address wrapper); function deployMulti(bytes32 conditionId, bytes32 parentCollectionId, uint256[] calldata indexSets) external returns (address[] memory wrappers); function getSupportedTokens() external view returns (address[] memory); function split( bytes32 conditionId, bytes32 parentCollectionId, uint256 amount, address tokenIn, address to ) external; function merge(address[] calldata wrappers, uint256 amount, address tokenOut, address to) external returns (uint256 amountOut); } ``` #### `TokenParams` The parameters of a wrapper's single position. - `conditionId`: the CTF condition the position belongs to. - `parentCollectionId`: the parent collection the position is nested under; `bytes32(0)` for a top-level position. - `indexSet`: the bitmask of outcome slots the position covers. - `positionId`: the position id derived from the three fields above, per **Definitions**. ```solidity struct TokenParams { bytes32 conditionId; bytes32 parentCollectionId; uint256 indexSet; uint256 positionId; } ``` #### Factory Methods ##### `conditionalTokens` The Conditional Tokens contract every wrapper of this factory wraps. - MUST be fixed at deployment and MUST NOT change. ```solidity function conditionalTokens() external view returns (address); ``` ##### `collateralToken` The collateral token positions are denominated in. - MUST be fixed at deployment and MUST NOT change. ```solidity function collateralToken() external view returns (address); ``` ##### `wrapperOf` The wrapper for a position id. - MUST return the registered wrapper for `positionId`, or `address(0)` if none is deployed. - MUST be the inverse of `paramsOf`: for any registered wrapper `w`, `wrapperOf(paramsOf(w).positionId) == w`. ```solidity function wrapperOf(uint256 positionId) external view returns (address); ``` ##### `paramsOf` The parameters recorded for a wrapper. - MUST return the parameters recorded for `wrapper` at deployment, unchanged on every subsequent call. - MUST return a zero-valued `TokenParams` for an unknown wrapper. ```solidity function paramsOf(address wrapper) external view returns (TokenParams memory); ``` ##### `isWrapper` Whether an address was deployed and registered by this factory. - MUST return `true` if and only if `wrapper` was deployed and registered by this factory. ```solidity function isWrapper(address wrapper) external view returns (bool); ``` ##### `getAddress` The deterministic address a wrapper for a position id would have. - MUST return the address a wrapper for `positionId` has, or would have. ```solidity function getAddress(uint256 positionId) external view returns (address); ``` ##### `siblingWrapperOf` The wrapper for another outcome of the same condition and parent collection as a registered wrapper. - MUST return the registered wrapper for the position id of `(paramsOf(wrapper).parentCollectionId, paramsOf(wrapper).conditionId, siblingIndexSet)`, or `address(0)` if none exists. - MUST revert for an unknown `wrapper`. Unlike `wrapperOf`, this call derives its answer from the parameters of the supplied wrapper, so a zero return would conflate "not a wrapper of this factory" with "sibling not yet deployed". ```solidity function siblingWrapperOf(address wrapper, uint256 siblingIndexSet) external view returns (address); ``` ##### `getSupportedTokens` The set of tokens accepted as `tokenIn` and delivered as `tokenOut`. - MUST return the exact set of tokens the factory accepts as `tokenIn` and delivers as `tokenOut`. - MUST include `collateralToken()`, and therefore MUST NOT be empty. - MUST NOT contain duplicates. ```solidity function getSupportedTokens() external view returns (address[] memory); ``` ##### `deploy` Deploys, or returns the existing, wrapper for a single outcome position. - MUST deploy and register a wrapper for the position id derived from its arguments, at `getAddress(positionId)`. - MUST record `TokenParams(conditionId, parentCollectionId, indexSet, positionId)`. - MUST emit the `WrapperCreated` event. - MUST be idempotent per position id: a second call for the same id MUST return the existing wrapper and MUST NOT emit a further event. - MUST revert if `indexSet` is zero, or sets any bit at or above `CTF.getOutcomeSlotCount(conditionId)`. ```solidity function deploy(bytes32 conditionId, bytes32 parentCollectionId, uint256 indexSet) external returns (address wrapper); ``` ##### `deployMulti` Deploys, or returns the existing, wrappers for several outcome positions of one condition. - MUST be equivalent to calling `deploy(conditionId, parentCollectionId, indexSet)` once per entry of `indexSets`, in order. - MUST return the resulting wrappers in the same order as `indexSets`. - MUST revert if `indexSets` is empty. ```solidity function deployMulti(bytes32 conditionId, bytes32 parentCollectionId, uint256[] calldata indexSets) external returns (address[] memory wrappers); ``` ##### `split` Splits `tokenIn` into a complete set of wrapped outcomes. - MUST take `amount` of `tokenIn` from `msg.sender`. - MUST deliver `amount` of each wrapper of that partition to `to`, deploying any that do not yet exist. - MUST emit the `Split` event. ```solidity function split( bytes32 conditionId, bytes32 parentCollectionId, uint256 amount, address tokenIn, address to ) external; ``` ##### `merge` Burns a complete outcome set and releases collateral, delivered as `tokenOut`. - MUST take `amount` of each wrapper in `wrappers` from `msg.sender`. - MUST deliver exactly `amount` of `tokenOut` to `to`. - MUST emit the `Merged` event. - MUST return `amount`. - MUST revert unless the entries of `wrappers` are all registered by this factory, share one `conditionId` and one `parentCollectionId`. - MUST revert if `amount` is zero, `to` is the zero address, or `tokenOut` is unsupported. ```solidity function merge(address[] calldata wrappers, uint256 amount, address tokenOut, address to) external returns (uint256 amountOut); ``` #### Factory Events ##### `WrapperCreated` MUST be emitted when a wrapper is deployed and registered, and MUST NOT be emitted again for the same position id. The event carries the full `TokenParams` so that a consumer reading logs alone can recompute `positionId` and verify the derivation. ```solidity event WrapperCreated( address indexed wrapper, uint256 indexed positionId, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 indexSet ); ``` ##### `Split` MUST be emitted on every successful `split`. ```solidity event Split( address indexed account, address indexed to, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 amountIn, address tokenIn ); ``` ##### `Merged` MUST be emitted on every successful `merge`. ```solidity event Merged( address indexed account, address indexed to, bytes32 indexed conditionId, bytes32 parentCollectionId, uint256 amountIn, address tokenOut, uint256 amountOut ); ``` ## Rationale ### Why a factory-plus-token interface, and not token-only The two capabilities builders need — reading a position's parameters and performing complete-set operations — are the ones a single wrapper cannot carry cheaply. Parameters would be duplicated into every per-outcome deployment, and `split` and `merge` span multiple positions and the collateral, so a wrapper performing them would need routing and sibling knowledge. Concentrating both in one factory per venue keeps wrappers minimal enough for a clone, while giving integrators one uniform place to read parameters and enter or exit sets. ### Why parameters live on the factory, not on every wrapper Wrappers are typically minimal-proxy clones, which cannot carry per-instance immutables. Holding parameters on the factory lets a wrapper expose only `factory()`, from which every read about it is reachable. ## Backwards Compatibility This proposal changes no [ERC-20](./eip-20.md) or [ERC-1155](./eip-1155.md) semantics and requires no change to deployed Conditional Tokens contracts. A factory and its wrappers sit entirely on top of an existing `CTF` and `COLLATERAL`, and a wrapper is an ordinary ERC-20 to every contract that only transfers it. One incompatibility is introduced, on the receiving side. ### Batch transfers into a wrapper A wrapper implements the ERC-1155 receiver interface but accepts single transfers only: its `onERC1155BatchReceived` always reverts. Reverting is a conformant response for an ERC-1155 receiver, so the underlying is never lost — the revert reverts the transfer — but contracts that treat any receiver as batch-capable will fail against a wrapper, and a wrapper offers no way to detect this ahead of the call. `ERC165.supportsInterface(type(IERC1155Receiver).interfaceId)` reports `true` for a wrapper, because the interface is implemented; it does not distinguish the two entry points. The severity is liveness, not loss of funds, and the affected callers are contracts that move ERC-1155 balances on a user's behalf — routers, vaults, and migration helpers — rather than end users. Such callers should route positions into a wrapper through `wrap`, or through `safeTransferFrom` one id at a time, and not through `safeBatchTransferFrom`. Wrappers are one-to-one with a position id, so batching gains nothing within a single wrapper regardless; a batch spanning several positions has to be split across their wrappers in any case. ## Security Considerations ### Parameter integrity `paramsOf` is the only on-chain source of truth about what a wrapper represents; a wrong `conditionId` or collateral lets a wrapper be priced against the wrong market. The derivation invariant is the defense, and it holds only if a factory never accepts a supplied position id and never mutates a registered wrapper's parameters. Integrators trusting a wrapper they did not deploy SHOULD confirm `isWrapper` under a factory they trust, and MAY recompute the invariant themselves from the fields of `WrapperCreated`. ### Double-minting on the receipt path A wrapper has two ways to receive the underlying, and `wrap` traverses both: the `safeTransferFrom` it performs invokes the wrapper's own `onERC1155Received`. An implementation that mints in both mints twice for one receipt, permanently breaking the supply invariant and letting the caller unwrap more than was deposited — draining the position balance backing every other holder. ### Supply invariant and burn authority The supply invariant holds only if a wrapper's supply is altered exclusively by its own `wrap` and `unwrap`. Neither wrappers nor the factory may introduce privileged burn hooks over holders' ERC-20 balances; the factory's complete-set operations are restricted to what any integrator could do with the same allowance. ### Reentrancy Complete-set operations call out to `CTF`, to wrappers, and to conversion adapters within a single transaction, and `unwrap` yields control to an arbitrary `to` through the ERC-1155 receiver hook. The supply invariant is stated over every state-changing call, which a reentrant path can violate mid-operation, so these operations MUST be guarded against reentrancy and MUST order state changes before external calls. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).