Abstract
This proposal standardizes the representation of Conditional Tokens Framework (CTF) positions — ERC-1155 prediction-market outcome shares — as ERC-20 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’sconditionalTokens(). -
COLLATERAL: the token returned by a factory’scollateralToken(). -
Position id: the ERC-1155 id of the tuple
(parentCollectionId, conditionId, indexSet)underCTFandCOLLATERAL, 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, the ERC-1155 receiver interface, and the following interface:
// 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 equalIERC1155(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.
wrapandunwrapMUST 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 thedecimals()ofCOLLATERAL. Because wrapping and splitting are both one-to-one, one ERC-20 unit of a wrapper is one unit ofCOLLATERALat settlement, and a mismatch would misprice the wrapper everywhere it is quoted.name()andsymbol()SHOULD be derived deterministically frompositionIdand SHOULD NOT be mutable.
Wrapper Methods
factory
The factory that governs this wrapper and holds its parameters.
- MUST return a contract conforming to
ICTFWrapperFactoryunder which this wrapper is registered —ICTFWrapperFactory(factory()).isWrapper(address(this))MUST betrue. - MUST NOT change.
function factory() external view returns (address);
wrap
Takes the underlying position from the caller and mints ERC-20 against it.
- MUST transfer
amountofpositionIdfrommsg.senderto the wrapper. - MUST mint
amountof ERC-20 toto. - The invocation of the wrapper’s own
onERC1155Receivedcaused by that inbound transfer MUST NOT mint. - MUST revert if
amountis zero. - MUST revert if
tois the zero address. - MUST NOT be gated on the settlement state of the condition.
function wrap(address to, uint256 amount) external;
unwrap
Burns ERC-20 from the caller and returns the underlying position.
- MUST burn
amountof ERC-20 frommsg.sender. - MUST transfer
amountofpositionIdtoto. - MUST emit the
Unwrappedevent. - MUST revert if
amountis zero. - MUST revert if
tois the zero address. - MUST NOT be gated on the settlement state of the condition.
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.senderis notCTF. - MUST revert if
idis notpositionId. - MUST NOT mint if the transfer originates from this wrapper’s own
wrap, which mints itself.
function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data)
external
returns (bytes4);
onERC1155BatchReceived
- MUST revert.
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.
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.
event Unwrapped(address indexed from, address indexed to, uint256 amount);
Factory Interface
Every conforming factory MUST implement the following interface:
// 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.
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.
function conditionalTokens() external view returns (address);
collateralToken
The collateral token positions are denominated in.
- MUST be fixed at deployment and MUST NOT change.
function collateralToken() external view returns (address);
wrapperOf
The wrapper for a position id.
- MUST return the registered wrapper for
positionId, oraddress(0)if none is deployed. - MUST be the inverse of
paramsOf: for any registered wrapperw,wrapperOf(paramsOf(w).positionId) == w.
function wrapperOf(uint256 positionId) external view returns (address);
paramsOf
The parameters recorded for a wrapper.
- MUST return the parameters recorded for
wrapperat deployment, unchanged on every subsequent call. - MUST return a zero-valued
TokenParamsfor an unknown wrapper.
function paramsOf(address wrapper) external view returns (TokenParams memory);
isWrapper
Whether an address was deployed and registered by this factory.
- MUST return
trueif and only ifwrapperwas deployed and registered by this factory.
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
positionIdhas, or would have.
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), oraddress(0)if none exists. - MUST revert for an unknown
wrapper. UnlikewrapperOf, 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”.
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
tokenInand delivers astokenOut. - MUST include
collateralToken(), and therefore MUST NOT be empty. - MUST NOT contain duplicates.
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
WrapperCreatedevent. - 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
indexSetis zero, or sets any bit at or aboveCTF.getOutcomeSlotCount(conditionId).
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 ofindexSets, in order. - MUST return the resulting wrappers in the same order as
indexSets. - MUST revert if
indexSetsis empty.
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
amountoftokenInfrommsg.sender. - MUST deliver
amountof each wrapper of that partition toto, deploying any that do not yet exist. - MUST emit the
Splitevent.
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
amountof each wrapper inwrappersfrommsg.sender. - MUST deliver exactly
amountoftokenOuttoto. - MUST emit the
Mergedevent. - MUST return
amount. - MUST revert unless the entries of
wrappersare all registered by this factory, share oneconditionIdand oneparentCollectionId. - MUST revert if
amountis zero,tois the zero address, ortokenOutis unsupported.
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.
event WrapperCreated(
address indexed wrapper,
uint256 indexed positionId,
bytes32 indexed conditionId,
bytes32 parentCollectionId,
uint256 indexSet
);
Split
MUST be emitted on every successful split.
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.
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 or ERC-1155 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.