CIP | Title | Category | Status | Authors | Implementors | Discussions | Created | License | |||
---|---|---|---|---|---|---|---|---|---|---|---|
113 |
Programmable token-like assets |
Tokens |
Proposed |
|
2023-01-14 |
CC-BY-4.0 |
This CIP proposes a standard that if adopted would allow the same level of programmability of other ecosistems at the price of the token true ownership.
This is achieved imitating the way the account model works laveraging the UTxO structure adopted by Cardano.
This CIP proposes a solution at the Cardano Problem Statement 3 (CPS-0003).
If adopted it would allow to introduce the programmability over the transfer of tokens (meta-tokens) and their lifecycle.
The solution proposed includes (answering to the open questions of CPS-0003):
-
very much like account based models, wallets supporting this standard will require to know the address of the smart contract (validator)
-
the standard defines what is expected for normal transfer transaction, so that wallets can independently construct transactions
-
the solution can co-exist with the existing native tokens
-
the implementation is possible without hardfork since Vasil
-
optimized implementations SHOULD NOT take significant computation, especially on transfers.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
In the specification we'll use the haskell data type Data
:
data Data
= Constr Integer [Data]
| Map [( Data, Data )]
| List [Data]
| I Integer
| B ByteString
we'll use the plu-ts syntax for structs definition as an abstraction over the Data
type.
finally, we'll use mermaid
to help with the visualization of transactions and flow of informations.
In order to do so we introduce here a legend
flowchart TD
subgraph relations
direction LR
a[ ] -.-> b[ ]
c[ ] <-.-> d[ ]
style a fill:#FFFFFF00, stroke:#FFFFFF00;
style b fill:#FFFFFF00, stroke:#FFFFFF00;
style c fill:#FFFFFF00, stroke:#FFFFFF00;
style d fill:#FFFFFF00, stroke:#FFFFFF00;
end
style relations fill:#FFFFFF00, stroke:#FFFFFF00;
subgraph entities
direction LR
A[speding contract]
pkh((pkh signer))
end
style entities fill:#FFFFFF00, stroke:#FFFFFF00;
subgraph scripts
direction LR
mint[(minting policy)]
obs([withdraw 0 observer])
end
style scripts fill:#FFFFFF00, stroke:#FFFFFF00;
subgraph utxos
direction LR
subgraph input/output
direction LR
inStart[ ] --o inStop[ ]
style inStart fill:#FFFFFF00, stroke:#FFFFFF00;
style inStop fill:#FFFFFF00, stroke:#FFFFFF00;
end
subgraph ref_input
direction LR
refInStart[ ]-.-orefInStop[ ]
style refInStart fill:#FFFFFF00, stroke:#FFFFFF00;
style refInStop fill:#FFFFFF00, stroke:#FFFFFF00;
end
subgraph minted_asset
direction LR
mntInStart[ ] -.- mntInStop[ ]
style mntInStart fill:#FFFFFF00, stroke:#FFFFFF00;
style mntInStop fill:#FFFFFF00, stroke:#FFFFFF00;
end
subgraph burned_asset
direction LR
brnInStart[ ] -.-x brnInStop[ ]
style brnInStart fill:#FFFFFF00, stroke:#FFFFFF00;
style brnInStop fill:#FFFFFF00, stroke:#FFFFFF00;
end
style input/output fill:#FFFFFF00, stroke:#FFFFFF00;
style ref_input fill:#FFFFFF00, stroke:#FFFFFF00;
style minted_asset fill:#FFFFFF00, stroke:#FFFFFF00;
style burned_asset fill:#FFFFFF00, stroke:#FFFFFF00;
end
style utxos fill:#FFFFFF00, stroke:#FFFFFF00;
subgraph transactions
direction LR
subgraph Transaction
.[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
end
subgraph transaction
direction LR
start[ ] --> stop[ ]
style start fill:#FFFFFF00, stroke:#FFFFFF00;
style stop fill:#FFFFFF00, stroke:#FFFFFF00;
end
style transaction fill:#FFFFFF00, stroke:#FFFFFF00;
end
style transactions fill:#FFFFFF00, stroke:#FFFFFF00;
The core idea of the implementation is to emulate the ERC20 standard; where tokens are entries in a map with addresses (or credentials in our case) as key and integers (the balances) as value. (see the OpenZeppelin implementation for reference);
Unlike the ERC20 standard; this CIP:
- allows for multiple entries with the same key (same credentials can be used for multiple accounts, though not particularly useful)
- DOES NOT include an equivalent of the
transferFrom
method; if needed it can be added by a specific implementation but it won't be considered part of the standard. - allows for multiple inputs (from the same sender, scripts and multisigs MAY also be used) and multiple outputs (possibly many distinct reveivers)
NOTE: Multiple Inputs
the UTxO model allows for multiple transfers in one transaction
this would allow for a more powerful model than the account based equivalent but implies higher execution costs
with the goal of keeping the standard simple we allow only a single sender
we MUST however account for multiple inputs from the same sender due to the creation of new UTxOs in receiving transactions.
with the introduction of CIP-69 in Plutus V3 the number of contracts required are only 2, covering different purposes.
- a
stateManager
contract- with minting policy used to proof the validity of account's state utxos
- spending validator defining the rules to update the states
- a
transferManager
parametrized with thestateManager
hash- having minting policy for the tokens to be locked in the spending validator
- spending validator for the validation of single utxos (often just forwarding to the withdraw 0)
- certificate validator to allow registering the stake credentials (always succeed MAY be used) and specify the rules for de-registration (always fail MAY be used, but some logic based on the user state is RECOMMENDED)
- withdraw validator to validate the spending of multiple utxos.
flowchart LR
subgraph transferManager
transferManagerPolicy[(transfer manager policy)]
transferManagerContract[transfer manager]
transferManagerObserver([transfer manager observer])
transferManagerPolicy -. mints CNTs .-> transferManagerContract
transferManagerContract <-. validates many inputs .-> transferManagerObserver
transferManagerContract -- transfer --> transferManagerContract
end
subgraph stateManager
stateManagerPolicy[(state manager policy)]
stateManagerContract[state manager]
stateManagerPolicy -. mints validity NFTs .-> stateManagerContract
stateManagerContract -- mutates state --> stateManagerContract
end
stateManager -. hash .-> transferManager
an external contract that needs to validate the transfer of a programmable token should be able to get all the necessary informations about the transfer by looking for the redeemer with withdraw purpose and transferManager
stake credentials.
The Account
data type is used as the stateManager
datum; and is defined as follows:
const Account = pstruct({
Account: {
credentials: PCredential.type,
state: data
}
});
The only rule when spending an utxo with Account
datum is that the NFT present on the utxo
stays in the same contract.
The standard does not impose any rules on the redeemer to spend the utxo, as updating the state is implementation-specific.
The TransferManagerDatum
data type is used as the transferManager
utxo datum; and is defined as follows:
const TransferManagerDatum = pstruct({
TransferManagerDatum: {
userStateTokenName: PTokenName.type, // alias of bytestring
state: PMaybe( data ).type // wallet may use `Nothing` constructor
}
});
the purpose of userStateTokenName
is to prevent the user from creating a new account at the stateManager
with same credentials and use it for this same utxo.
This makes sure only one Account
is valid for a given utxo, even if possibly many Account
s may exsist for the same credentials.
A specific implementation MAY additionaly implement other checks to ensure unique accounts, such as centralized actors, or merkle-patricia trees.
redeemer to be used in the withdraw 0 contract
when spending (possibly many) utxos from the transferManager
contract.
NOTE:
there is no standard datum for the
transferManager
since the utxos might not have a datum at all
const TransferOutput = pstruct({
TransferOutput: {
credential: PCredential.type,
amount: int,
stateIndex: int
}
});
const TransferRedeemer = pstruct({
Transfer: {
totInputAmt: int,
outputs: list( TransferOutput.type ),
stateIndex: int
firstOutputIndex: int,
// inputIndicies: list( int ), // filter
}
});
redeemer to be used on a single utxo of the transferManager
;
const SingleTransferRedeemer = pstruct({
Transfer: {},
Burn: {},
});
flowchart LR
stateManagerPolicy[(state manager policy)]
subgraph transaction
.[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
end
stateManagerContract[state manager]
stateManagerPolicy -. validity NFTs .-> transaction
--o stateManagerContract
The purpose of the "New Account generation" transaction is to create a new account for a user and validate the initial state of an account.
The validation is performed in the minting policy.
The minting policy MUST succeed if only one asset is created under the policy respecting the naming convention explained below, and MAY succeed if other assets are created as long as their name is NOT of length 32.
The name of the asset MUST be derived from the first input's utxo reference spent by the minting transaction.
in particular MUST be the result of applying the serialised (serialiseData CIP
and opinionated standard serialization in Haskell) utxo reference according to the standard plutus v3 onchain type definition (where txId
is an alias of a bytestring, and NOT a bytestring wrapped in a constr 0 like previous plutus versions) to the builtin sha3_256
.
In textual uplc:
(lam utxoRefData
[
(builtin sha3_256)
[
(builtin serialiseData)
utxoRefData
]
]
)
at all times only one (1) NFT under stateManager
policy MUST be present on a stateManager
utxo.
The transaction MUST have one output going to the stateManager
having correct Account
inline datum.
by correct Account
datum is implied the data is a constructor with index 0, first field being the credentials of the user, and second field being the state associated.
Additional checks regarding the state are left to the implementation.
flowchart LR
subgraph transaction
.[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
end
stateManagerContract[state manager]
stateManager[state manager]
stateManager --o transaction
--o stateManagerContract
The purpose of this transaction is to modify the state of an account.
The only two rules regarding this transaction are:
- The NFT present in the input MUST be preserved in the output going back to the
stateManager
- the credentials (first field of the datum) associated to an NFT MUST be preserved for that NFT (ie. the output having the nft has
Account
datum with first field equal to the first field of the input datum).
flowchart LR
subgraph transaction
.[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
end
stateManager[state manager]
transferManagerPolicy[(transfer manager policy)]
transferManagerContract[transfer manager]
stateManager -. receiver account state .-o transaction
transferManagerPolicy -. mints CNTs .-> transaction
-- minted CNTs --o transferManagerContract
The "Minting Tokens" transaction allows to create new programmable tokens.
Programmable tokens are normal CNTs that are only present on transferManager
contract with same hash as their own policy.
If, by incorrect implementation of the standard, these tokens are found outside the respective transfer manager, they are no longer to be considered as programmable tokens.
The transaction MUST have an output going to the transferManager
contract with the correct TransferManagerDatum
inline datum.
The first field of the datum MUST be the name of the NFT present on the reference input coming from the state manager.
Additional, implementation-specific, arbitrary logic (eg. capped supply, or one-time mints, etc. ) MAY be added.
flowchart LR
subgraph transaction
.[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
end
stateManager[state manager]
transferManagerContract[transfer manager]
stateManager -. sender account state .-o transaction
transferManagerContract -- Burn Redeemer --o transaction -..-x a[ ]
style a fill:#FFFFFF00, stroke:#FFFFFF00;
If an utxo in the transferManager
is spent with Burn
redeemer, the entire amount of the programmable token on that utxo MUST be burnt.
Only one (1) utxo from the transferManager
is allowed to be spent in a burn transaction.
If a user desires to burn a different amount than the one currently present they should break the operation in 2 different transactions:
one Transfer
transaction to create the utxo with that amount.
one Burn
transaction spending the desired utxo.
Additional, implementation-specific, arbitrary logic MAY be added.
flowchart LR
transferManagerObserver([transfer manager observer])
stateManagerContract[state manager]
transferManagerContract[transfer manager]
sender((sender))
A[transfer manager]
B[transfer manager]
same[transfer manager]
subgraph transaction
direction LR
.[ ]
_[ ]
z[ ]
style . fill:#FFFFFF00, stroke:#FFFFFF00;
style _ fill:#FFFFFF00, stroke:#FFFFFF00;
style z fill:#FFFFFF00, stroke:#FFFFFF00;
end
transferManagerObserver -. validates inputs .-> transaction
stateManagerContract -. sender account state .-o transaction
stateManagerContract -. receiver A account state .-o transaction
stateManagerContract -. receiver B account state .-o transaction
transferManagerContract --o transaction
transferManagerContract -- possibly --o transaction
transferManagerContract -- many --o transaction
transferManagerContract -- inputs --o transaction
transferManagerContract --o transaction
sender -. signs .-> transaction
transaction -- stake creds A --o A
transaction -- stake creds B --o B
transaction -- change (if needed) --o same
The Transfer
transaction allows the transfer of programmable tokens from one account to another within the transferManager
contract. This transaction involves the following components:
- transferManagerObserver: The contract that validates the inputs and ensures the correctness of the transfer.
- stateManagerContract: The contract that manages the state of the accounts involved in the transfer.
- transferManagerContract: The contract that handles the actual transfer of tokens.
The purpose of this transaction is to transfer tokens from the sender's account to one or more receiver accounts. The transaction MUST ensure that the transfer is valid according to the states of each account involved if necessary.
The transaction MUST call the transferManagerObserver
contract with the TransferRedeemer
. External contracts should look for this redeemer to obtain information regarding the transfer.
Additionally, any UTxOs spent from the transferManager
contract MUST be spent with the SingleTransferRedeemer
with Transfer
constructor (index 0
).
The transaction MUST include:
- One or more inputs from the sender's account in the
transferManager
withSingleTransferRedeemer
for each UTxO spent. - Outputs to the receives' accounts in the
transferManager
as specified in theTransferRedeemer
of the transfer manager observer. - The
TransferRedeemer
for thetransferManager
(as observer) contract. - one
Account
reference input for each of the parties involved (sender + receivers).
The information in the TransferRedeemer
MUST be validated by the observer.
That includes that the sum of the programmable tokens coming from the transferManager
is stirctly equal to totInputAmt
.
There is one output for each element of the 2nd field (outputs
) starting at the index specified by the 4th field (firstOutputIndex
), in the same order.
Each of the outputs going to the transferManager
MUST have TransferManagerDatum
inline datum, and MUST make sure the first field of the datum matches the token name of the NFT present on the respective reference input Account
.
The first proposed implementation (which we could informally refer as v0) was quite different by the one shown in this document
Main differences were in the proposed:
- use of sorted merkle trees to prove uniqueness of an account during creation;
- account credentials as asset name
this path was abandoned due to the logaritmic cost of creation of accounts, on top of the complexity.
Other crucial difference with the first proposed implementation was in the accountManager
redeemers;
which included definitions for TransferFrom
, Approve
and RevokeApproval
redeemers, aiming to emulate ERC20's methods of transferFrom
and approve
;
after important feedback by the community, it was noted that such methods would not only have been superfluous, but also dangerous, and are hence removed in this specification.
After a first round of community feedback, a reviewed stadard was proposed (which we could informally refer to as v1). This first revision even had a PoC implementation, but after further feedback from the community it was noted that the need to spend an utxo on the receiving side could cause UTxO contention in the moment two or more parties would have wanted to send a programmable token to the same receiver at the same time.
The specification proposed in this file addresses all the previous concerns.
The proposal does not affect backward compatibilty being the first proposing a standard for programmability over transfers;
exsisting native tokens are not conflicting for the standard, instead, native tokes are used in this specification for various purposes.
- having at least one instance of the smart contracts described on:
- mainnet
- preview testnet
- preprod testnet
- having at least 2 different wallets integrating meta asset functionalities, mainly:
- displayning balance of a specified meta asset if the user provides the address of the respecive account manager contract
- independent transaction creation with
Transfer
redeemers
- PoC implementation
- showcase transactions
- wallet implementation
This CIP is licensed under CC-BY-4.0.