Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
- [Identities](data-model/identities.md)
- [Key Budgets and Expiry](data-model/key-limits.md)
- [Token Shielded Pools](data-model/token-shielded-pools.md)
- [Non-Transferable Tokens](data-model/non-transferable-tokens.md)

# Contract Keywords

Expand Down
7 changes: 5 additions & 2 deletions book/src/contract-keywords/token-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| **Default** | Absent: no action costs tokens |
| **Since** | protocol version 9. `gasFeesPaidBy` is accepted from 9 and acted on from 14; `optional` is 14 |
| **On update** | Fixed: a cost may not be added, changed or removed on an existing document type (`DocumentTypeUpdateError`, 40212) |
| **Errors** | On a document transition: `RequiredTokenPaymentInfoNotSetError` (40115), `IdentityHasNotAgreedToPayRequiredTokenAmountError` (40116), `IdentityTryingToPayWithWrongTokenError` (40117), `IdentityTokenAccountFrozenError` (40702), `IdentityDoesNotHaveEnoughTokenBalanceError` (40700), `GasFeesPaidByNotAllowedError` (40129), `InconsistentGasFeesPaidByInBatchError` (40130), `GasSponsorInsufficientBalanceError` (40222). At registration: `InvalidTokenPositionError` (10451), `RedundantDocumentPaidForByTokenWithContractId` (10275), `TokenPaymentByBurningOnlyAllowedOnInternalTokenError` (10261), `DataContractNotFoundError` (40008), `InvalidTokenPositionStateError` (40009) |
| **Errors** | On a document transition: `RequiredTokenPaymentInfoNotSetError` (40115), `IdentityHasNotAgreedToPayRequiredTokenAmountError` (40116), `IdentityTryingToPayWithWrongTokenError` (40117), `IdentityTokenAccountFrozenError` (40702), `IdentityDoesNotHaveEnoughTokenBalanceError` (40700), `GasFeesPaidByNotAllowedError` (40129), `InconsistentGasFeesPaidByInBatchError` (40130), `GasSponsorInsufficientBalanceError` (40222). At registration: `InvalidTokenPositionError` (10451), `RedundantDocumentPaidForByTokenWithContractId` (10275), `TokenPaymentByBurningOnlyAllowedOnInternalTokenError` (10261), `NonTransferableTokenPaymentMustBurnError` (10280), `DataContractNotFoundError` (40008), `InvalidTokenPositionStateError` (40009), `TokenNotTransferableError` (40726) |

The keys of each cost object:

Expand Down Expand Up @@ -86,6 +86,8 @@ The signer pays: the creator for a create, the owner for a replace, delete, tran
- `0`, the default, moves the tokens from the signer to the owner of the contract that holds the document type. When the contract owner performs the action themselves nothing moves, though their balance is still checked.
- `1` burns the tokens from the signer's balance, lowering the token's supply. Only a token of the contract's own can be burned (`TokenPaymentByBurningOnlyAllowedOnInternalTokenError`, 10261, at registration).

A [non-transferable token](../data-model/non-transferable-tokens.md) (protocol version 14) can only be burned: paying the contract owner would move it to another identity, so a cost in the contract's own non-transferable token must set `effect: 1` (`NonTransferableTokenPaymentMustBurnError`, 10280, at registration). Another contract's non-transferable token cannot be charged at all, since it cannot be burned (`TokenNotTransferableError`, 40726, at registration).

## Tokens of another contract: `contractId`

A document type may charge a token another contract defines, for example a shared currency. `contractId` names that contract and `tokenPosition` the token in it. The effect must then be `0`: the tokens go to the owner of the contract holding the document type, not to the token's issuer. At registration the named contract must exist (`DataContractNotFoundError`, 40008) and have a token at that position (`InvalidTokenPositionStateError`, 40009), and it must not be the contract itself: leave `contractId` out for the contract's own tokens (`RedundantDocumentPaidForByTokenWithContractId`, 10275).
Expand Down Expand Up @@ -119,7 +121,8 @@ Together with `gasFeesPaidBy` this gives a "free usage" pattern: an app hands ou

- The meta-schema checks the shape (`JsonSchemaError`, 10101): only the six action keys; `tokenPosition` and `amount` required in each cost; values in the ranges above; no other key. Before protocol version 14 it also refuses `optional`.
- Without `contractId`, `tokenPosition` must be a token of this contract (`InvalidTokenPositionError`, 10451).
- With `contractId`: not this contract's own id (10275), no burn (10261), and a contract that exists with a token at that position (40008, 40009).
- With `contractId`: not this contract's own id (10275), no burn (10261), and a contract that exists with a token at that position (40008, 40009). From protocol version 14 that token must be transferable (40726).
- Without `contractId`, from protocol version 14, a non-transferable token must be burned (10280).

## See also

Expand Down
70 changes: 70 additions & 0 deletions book/src/data-model/non-transferable-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Non-Transferable Tokens

From protocol version 14 a token can be non-transferable: once an identity holds it, no one can
move it to another identity, directly or through a contract. The token can still be minted,
claimed, bought, burned, frozen and paused as its rules allow, and it can still pay for the
documents of its own contract, by being burned. Reach for it when a token is a usage allowance,
points or a reputation score rather than a currency: an app hands it out, users spend it on the
app's own actions, and it never becomes something to sell or give away.

## Configuration

`TokenConfiguration` format version 1 carries `transferable: bool`, `true` when absent. A
version 0 configuration is always transferable, so only a format version 1 configuration can set
it to `false`, and protocol versions before 14 refuse that format at contract create and update.

```json
{
"$formatVersion": "1",
"baseSupply": 0,
"transferable": false,
...
}
```

The flag is fixed when the token is created. No `TokenConfigUpdate` item changes it, and a
contract update cannot change an existing token's configuration.

## What a non-transferable token still does

- **Minting.** The minting rules decide who mints and whether they may choose the destination,
as for any token. This is how an issuer hands the token out; a base supply is minted to
`newTokensDestinationIdentity`, or to the contract owner when that is unset, and whoever
receives it cannot send it on either.
- **Distributions and claims.** Perpetual, pre-programmed and once-per-identity distributions mint
to their recipients.
- **Direct purchase.** A purchase mints the tokens to the buyer and pays the price in credits.
- **Burning, freezing, unfreezing, destroying frozen funds and pausing**, under their own rules.
- **Paying for documents by burning.** A document type of the token's own contract may charge it
with `effect: 1` ([`tokenCost`](../contract-keywords/token-cost.md#effect-transfer-or-burn)).

## What is refused

| Operation | Refused with | Where |
|---|---|---|
| A `TokenTransfer` of the token | `TokenNotTransferableError` (40726) | Batch advanced structure validation, from the contract the action carries: the mempool refuses it, and a block charges the signer and bumps the nonce |
| A document type of the token's own contract charging it with `effect: 0` (pay the contract owner) | `NonTransferableTokenPaymentMustBurnError` (10280) | Contract create and update, when the document type is parsed. A document payment refuses it again (`TokenNotTransferableError`, 40726) from the configuration the contract already carries, so a contract that bypassed registration cannot pay with it either |
| A document type of another contract charging it | `TokenNotTransferableError` (40726) | Contract create and update state validation, which already reads the token's contract to check the token exists. Such a cost always pays the contract owner, since another contract's token cannot be burned (10261) |
| `hasShieldedPool: true` | `NonTransferableTokenShieldedPoolError` (10279) | Contract create and update, before anything is charged |

A shielded pool is refused because a note spent inside the pool can be unshielded to any
identity, so shielding and unshielding would move the token between holders.

Every rule is checked when the contract is registered or when the transfer arrives, and the flag
cannot change afterwards: a document type that could pay the owner in a non-transferable token
is never registered. A payment in the contract's own token checks the flag again at no read
cost; another contract's token is not read at payment time, since a cost in it was refused at
registration.

A non-transferable token has no pool, so the `TokenConfigUpdate` items that change the pool's
`minimumPoolNotesForOutgoing` and its rules are refused for it, even though its configuration is
format version 1.

## Why not pause the token

A paused token refuses transfers, but from protocol version 14 a paused token also refuses every
document payment, burns included (`TokenIsPausedError`, 40711). Before protocol version 14 a
paused token could still pay for documents, and since any contract may charge another contract's
token and pay it to its own owner, anyone could move a paused token by registering such a
contract. A non-transferable token closes that path at registration and keeps its document
payments working.
6 changes: 5 additions & 1 deletion book/src/data-model/token-shielded-pools.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ trees are created when a contract with the flag is inserted or updated.
## Configuration

`TokenConfiguration` gains a format version 1. It adds `hasShieldedPool: bool`, the optional
`minimumPoolNotesForOutgoing` and the `minimumPoolNotesForOutgoingChangeRules` that govern it.
`minimumPoolNotesForOutgoing` and the `minimumPoolNotesForOutgoingChangeRules` that govern it,
and `transferable` ([Non-Transferable Tokens](non-transferable-tokens.md)).
A version 0 configuration behaves as `hasShieldedPool: false`.

`minimumPoolNotesForOutgoing` is how many notes a pool must hold before tokens may leave it.
Expand Down Expand Up @@ -87,6 +88,9 @@ balances, a token with `hasShieldedPool` must disable them permanently: `freezeR
`unfreezeRules` and `destroyFrozenFundsRules` must each authorize no one to take the action and
have no admin action takers, so no later configuration update can switch them on. Contract
create and update reject anything else with `TokenShieldedPoolIncompatibleRulesError` (10278).
A pooled token must also be transferable: a note spent inside the pool can be unshielded to any
identity, so contract create and update reject `transferable: false` with a pool
(`NonTransferableTokenShieldedPoolError`, 10279).
Pausing still works: every pool operation, inflows included (shield, mint, claim and purchase
into the pool) and outflows (unshield, shielded transfer, burn from the pool), is rejected while
the token is paused. A pooled token can never freeze an account, so the pool validators read and bill no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use crate::data_contract::associated_token::token_configuration::accessors::v0::
use crate::data_contract::associated_token::token_configuration::accessors::v1::{
TokenConfigurationV1Getters, TokenConfigurationV1Setters,
};
use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0;
use crate::data_contract::associated_token::token_configuration::v0::{
default_change_control_rules, TokenConfigurationV0,
};
use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1;
use crate::data_contract::associated_token::token_configuration::TokenConfiguration;
use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention;
Expand Down Expand Up @@ -255,13 +257,20 @@ impl TokenConfigurationV1Getters for TokenConfiguration {
TokenConfiguration::V1(v1) => v1.minimum_pool_notes_for_outgoing(),
}
}

fn is_transferable(&self) -> bool {
match self {
TokenConfiguration::V0(_) => true,
TokenConfiguration::V1(v1) => v1.is_transferable(),
}
}
}

impl TokenConfigurationV1Setters for TokenConfiguration {
/// The representation stays canonical: enabling the pool on a V0 configuration upgrades
/// it in place to V1, and disabling it on a V1 configuration downgrades it back to V0, so
/// a configuration without a pool always serializes as format version 0 and is accepted
/// by every protocol version.
/// it in place to V1, and disabling it on a transferable V1 configuration downgrades it
/// back to V0, so a configuration using no V1 feature always serializes as format version 0
/// and is accepted by every protocol version.
fn set_has_shielded_pool(&mut self, has_shielded_pool: bool) {
match self {
TokenConfiguration::V0(v0) => {
Expand All @@ -272,16 +281,73 @@ impl TokenConfigurationV1Setters for TokenConfiguration {
}
}
TokenConfiguration::V1(v1) => {
if has_shielded_pool {
v1.set_has_shielded_pool(true);
} else {
let base = std::mem::replace(
&mut v1.base,
TokenConfigurationV0::default_most_restrictive(),
);
*self = TokenConfiguration::V0(base);
v1.set_has_shielded_pool(has_shielded_pool);
if !has_shielded_pool {
// The threshold and its rules only apply to a pool. A non-transferable
// token stays at V1 without one, so they are cleared here rather than left
// to the downgrade.
v1.minimum_pool_notes_for_outgoing = None;
v1.minimum_pool_notes_for_outgoing_change_rules =
default_change_control_rules();
}
self.downgrade_to_v0_if_unused();
Comment thread
QuantumExplorer marked this conversation as resolved.
}
}
}

/// Canonical like `set_has_shielded_pool`: making a V0 configuration non-transferable
/// upgrades it to V1, and making a V1 configuration without a pool transferable again
/// downgrades it to V0.
fn set_transferable(&mut self, transferable: bool) {
match self {
TokenConfiguration::V0(v0) => {
if !transferable {
let base =
std::mem::replace(v0, TokenConfigurationV0::default_most_restrictive());
let mut v1 = TokenConfigurationV1::from_v0(base, false);
v1.set_transferable(false);
*self = TokenConfiguration::V1(v1);
}
}
TokenConfiguration::V1(v1) => {
v1.set_transferable(transferable);
self.downgrade_to_v0_if_unused();
}
}
}
}

impl TokenConfiguration {
/// Sets the shielded pool's outgoing notes threshold, which only a configuration that has a
/// pool can carry. A threshold asked for on a token without a pool (format 0, or a
/// non-transferable format 1 token) is refused, returning `false` and changing nothing;
/// `None` always succeeds and clears whatever threshold is held.
pub fn set_minimum_pool_notes_for_outgoing(
&mut self,
minimum_pool_notes_for_outgoing: Option<u64>,
) -> bool {
match self {
TokenConfiguration::V1(v1)
if v1.has_shielded_pool || minimum_pool_notes_for_outgoing.is_none() =>
{
v1.minimum_pool_notes_for_outgoing = minimum_pool_notes_for_outgoing;
true
}
TokenConfiguration::V0(_) => minimum_pool_notes_for_outgoing.is_none(),
TokenConfiguration::V1(_) => false,
}
}

/// Replaces a V1 configuration that has no pool and is transferable by its V0 base.
fn downgrade_to_v0_if_unused(&mut self) {
if let TokenConfiguration::V1(v1) = self {
if !v1.has_shielded_pool && v1.transferable {
let base = std::mem::replace(
&mut v1.base,
TokenConfigurationV0::default_most_restrictive(),
);
*self = TokenConfiguration::V0(base);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
/// Getters introduced by `TokenConfigurationV1`.
///
/// Implemented for every configuration version: a V0 configuration answers `false` and 0,
/// since neither the shielded pool opt-in nor its threshold existed before V1.
/// Implemented for every configuration version: a V0 configuration answers `false`, 0 and
/// transferable, since neither the shielded pool opt-in, its threshold nor non-transferable
/// tokens existed before V1.
pub trait TokenConfigurationV1Getters {
/// Whether the token has its own shielded pool.
fn has_shielded_pool(&self) -> bool;

/// The fewest notes the token's shielded pool must hold before tokens may leave it for a
/// visible destination; 0 when the configuration sets none.
fn minimum_pool_notes_for_outgoing(&self) -> u64;

/// Whether holders may move the token to another identity.
fn is_transferable(&self) -> bool;
}

/// Setters introduced by `TokenConfigurationV1`.
pub trait TokenConfigurationV1Setters {
/// Sets whether the token has its own shielded pool.
fn set_has_shielded_pool(&mut self, has_shielded_pool: bool);

/// Sets whether holders may move the token to another identity.
fn set_transferable(&mut self, transferable: bool);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ impl TokenConfiguration {

impl TokenConfigurationV1 {
/// Determines whether a `TokenConfigurationChangeItem` can be applied: the shielded pool's
/// threshold items under `minimum_pool_notes_for_outgoing_change_rules`, every other item
/// under the nested V0 configuration's rules.
/// threshold items under `minimum_pool_notes_for_outgoing_change_rules`, and only on a token
/// that has a pool (a non-transferable token is V1 without one), every other item under the
/// nested V0 configuration's rules.
pub fn can_apply_token_configuration_item(
&self,
change_item: &TokenConfigurationChangeItem,
Expand All @@ -63,6 +64,13 @@ impl TokenConfigurationV1 {
) -> bool {
let rules = &self.minimum_pool_notes_for_outgoing_change_rules;
match change_item {
TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_)
| TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_)
| TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_)
if !self.has_shielded_pool =>
{
false
}
TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) => {
rules.can_make_change(contract_owner_id, main_group, groups, action_taker, goal)
}
Expand Down
Loading
Loading