Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
b7bd591
feat(platform)!: token marketplace listings (PV15)
QuantumExplorer Oct 21, 2026
2ed1dad
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 21, 2026
e31796b
refactor(drive-abci): check listings' state-free rules in check_tx
QuantumExplorer Oct 21, 2026
3df38e3
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 21, 2026
853b4a6
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 21, 2026
3b12a5b
fix(drive): pass the listing escrow its batch's earlier operations
QuantumExplorer Oct 21, 2026
57d0050
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
47b4e5c
fix(platform): token listings review round 1
QuantumExplorer Oct 22, 2026
2248eec
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
e6620ea
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
a682542
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
ce96fcb
fix(drive): prove a listing's placement after it is partly filled
QuantumExplorer Oct 22, 2026
f4a0e71
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
d4ab767
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
3bcc656
fix(platform): a listing signs the decimals its price is at
QuantumExplorer Oct 22, 2026
85d7fc9
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
95fcf70
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
c39d28c
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
494cea1
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 22, 2026
cd0380f
fix(drive): refuse a listing placement carrying group information in …
QuantumExplorer Oct 22, 2026
6bb00b0
fix(drive): a marketplace proof binds the token id to its contract an…
QuantumExplorer Oct 22, 2026
3127618
fix(platform): listing review round 4
QuantumExplorer Oct 22, 2026
f94a22e
test(platform): pin listing width bounds and the revision byte a fill…
QuantumExplorer Oct 23, 2026
88599ae
test(drive-abci): the revision-byte test uses a token that keeps no t…
QuantumExplorer Oct 23, 2026
1cc8a23
fix(platform): listing review round 5
QuantumExplorer Oct 23, 2026
2060a1b
fix(drive): a listing pays for the hash that derives its id
QuantumExplorer Oct 23, 2026
4767255
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 23, 2026
945b9ef
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 23, 2026
c896407
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 23, 2026
10f4c70
feat(platform)!: listings carry no expiry
QuantumExplorer Oct 23, 2026
5fe8a03
docs(platform): a fill's absolute escrow write depends on the one-tra…
QuantumExplorer Oct 23, 2026
26c35af
fix(drive): order reads add their cost with checked fee arithmetic
QuantumExplorer Oct 23, 2026
2483383
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 25, 2026
1f9875c
docs(platform-version): renumber the token listings note after the merge
QuantumExplorer Oct 25, 2026
22fc30a
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 25, 2026
d99411d
docs(platform-version): renumber the Token listings note after the merge
QuantumExplorer Oct 25, 2026
8e37f1b
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 25, 2026
c15044a
fix(dpp): orders of tokens with 39 or more decimals are priced, not r…
QuantumExplorer Oct 25, 2026
be63e4c
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 25, 2026
848d3bc
docs(platform-version): note 9 records how orders of tokens with 39 o…
QuantumExplorer Oct 25, 2026
6d05060
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
c589b9a
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
73f1da2
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
7b69cae
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
18e9f38
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
01a6340
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 26, 2026
6f00eff
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 27, 2026
21fa8b1
docs(platform-version): the listings note is note 10 of protocol vers…
QuantumExplorer Oct 27, 2026
22a4162
feat(platform)!: a listing takes its token's minimum fill and decimals
QuantumExplorer Oct 27, 2026
aaba891
docs(platform-version): indent note 10's continuation under its two-d…
QuantumExplorer Oct 27, 2026
ebc2b5c
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 27, 2026
978a91a
Merge branch 'claude/token-market-escrow-contract' into claude/token-…
QuantumExplorer Oct 28, 2026
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
116 changes: 107 additions & 9 deletions book/src/data-model/token-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ price and each purchase mints new tokens and pays the contract owner.

## Making a token tradeable

A token opts in through its `marketplaceRules`:
A token opts in through its `marketplaceRules`, with its minimum fill:

```json
"marketplaceRules": {
"$formatVersion": "0",
"tradeMode": "Tradeable",
"tradeMode": { "Tradeable": { "minFill": 1000 } },
"tradeModeChangeRules": {
"$formatVersion": "0",
"authorizedToMakeChange": { "$type": "contractOwner" },
Expand All @@ -28,6 +28,16 @@ A token opts in through its `marketplaceRules`:
}
```

`minFill` is the smallest amount, in base units, a fill of one of the token's orders takes,
unless it takes everything the order has left, and the least a fill leaves open: no order is
ever left holding less than a trade. An order of less is refused when it is placed
(`InvalidTokenOrderFillRangeError`, 10463, the minimum and the amount). It is the token's, not the
order's: every fill reads it as the token has it when the fill executes, so raising or lowering it
(a `MarketplaceTradeMode` configuration update, under `tradeModeChangeRules`) applies to the open
orders too. An order already holding less than a raised minimum is still filled, whole. It is from
1 to `i64::MAX` (`InvalidTokenOrderFillRangeError` on contract create or update, or on the
configuration update).

| `tradeMode` | Meaning |
|---|---|
| `NotTradeable` (the default) | Holders cannot list the token or make offers for it on Platform |
Expand Down Expand Up @@ -65,13 +75,19 @@ offer), each with three indexes:

Each side sums what it holds in escrow, so the sums cannot overflow: the tokens listed never exceed
the token's supply, and the credits offered never exceed the credits on Platform. Every integer of
an order is a signed 8-byte value, so a partial fill rewrites an order without changing its stored
size.

An order's price is in credits per whole token, and each order records the token's decimals when
it was placed (`decimals`). Every fill prices the order at that scale, so a later change to the
token's decimals (a `Conventions` update) changes neither what an open listing costs nor what an
open offer pays. The `book` and `depth` indexes keep each scale apart (`tokenId`, then `decimals`,
an order is a signed 8-byte value, so a partial fill rewrites them without changing their size. The
document's `$revision` is a variable-length integer, 7 bits to a byte: a fill that takes an order's
revision past one of those widths (from 127 to 128, then from 16,383 to 16,384, and so on) adds a
byte, and no other fill adds storage.

An order's price is in credits per whole token, so the token's decimals set what it is worth. The
signer names no decimals: each order records the token's decimals as the token has them when the
order is placed (`decimals`, declared `generatedFrom` `sys.tokens.decimals` of `tokenId`, so it
is generated when the order is created and kept after). Every fill prices the order at that
scale, so a later change to the token's decimals (a `Conventions` update) changes neither what an
open listing costs nor what an open offer pays. Whoever controls the token's conventions can
change its decimals while a signed order waits to execute, and the order is then placed at the new
scale: a listing asks, and an offer escrows, its price per whole token of the new decimals. The `book` and `depth` indexes keep each scale apart (`tokenId`, then `decimals`,
then `price`), so a book query and a depth sum name the token's decimals and never mix prices of
different scales. After a token changes its decimals, the orders placed before the change stay
under the old decimals until they close; a client showing them alongside the new ones queries both
Expand All @@ -90,3 +106,85 @@ What an order holds is kept outside the contract:
- The credits of an offer sit in `[PreFundedSpecializedBalances, PREFUNDED_BALANCES_FOR_TOKEN_ORDERS]`,
a SumTree keyed by order id, which the credit conservation check already counts.


## Listings

Three token transitions of a batch work with listings. Each is refused before protocol version
15, unpaid, by the batch's `is_allowed` gate (`StateTransitionNotActiveError` 10603, as
`TokenPlaceAsk`, `TokenFillAsk` or `TokenCancelOrder`).

| Transition | Fields | Effect |
|---|---|---|
| `TokenPlaceAsk` | `amount`, `price` | Moves `amount` base units from the seller's balance into escrow and records an `ask` |
| `TokenFillAsk` | `orderId`, `minAmount`, `maxAmount`, `maxTotalPrice` | Moves part or all of the listing from escrow to the buyer, and its cost from the buyer's credits to the seller's |
| `TokenCancelOrder` | `orderId`, `side` | Returns what an order holds to its owner and deletes the order |

A listing's id follows from the token, the seller and the transition's identity contract nonce
(`TokenOrderSide::order_id`), so a client knows it before the listing is placed.

### Price and partial fills

`price` is in credits per whole token, 10 to the power of the token's decimals base units. The
listing records those decimals when it is placed and every fill prices it at them, so a later
change to the token's decimals does not change what the rest of the listing costs. A fill
takes as much as it may: everything open, up to `maxAmount`. A listing never keeps less than the
token's `minFill` open, so a fill that would leave less takes less, leaving exactly `minFill`; a
fill that does not take everything open takes at least `minFill`. The fill is refused when the
amount it would take is under `minAmount`, or when it costs more than `maxTotalPrice`.

Each fill pays the change in the listing's running total rounded up, so splitting a purchase
never changes what it costs in all:

```text
2 decimals, 333 credits per token (3.33 per base unit), a listing of 3 base units
one fill of 3: 10 credits
three fills of 1: 4 + 3 + 3 = 10 credits
```

The rounding favours the seller and is never more than one credit per fill.

### Rules

| Rule | Refused with |
|---|---|
| `amount` and `price` from 1 to `i64::MAX` | `InvalidTokenAmountError` (10458), `InvalidTokenOrderPriceError` (10462) |
| A fill's `minAmount` from 1 to `maxAmount`, `maxAmount` up to `i64::MAX` | `InvalidTokenOrderFillRangeError` (10463) |
| The contract defines the token | `ContractHasNoTokensError`, `InvalidTokenPositionError` |
| The token is tradeable (to place or fill) | `TokenNotTradeableError` (40750) |
| `amount` at least the token's `minFill`, and what the order is worth at the token's decimals at most `i64::MAX` credits | `InvalidTokenOrderFillRangeError` (10463), `InvalidTokenOrderValueError` (10465) |
| The order is open, for this token | `TokenOrderNotFoundError` (40751) |
| The fill range meets the listing | `TokenOrderFillAmountError` (40753) |
| The fill costs at most `maxTotalPrice` | `TokenOrderPriceLimitError` (40754) |
| Only the order's owner cancels it | `TokenOrderNotOwnedError` (40755) |
| A seller does not fill their own listing | `TokenOrderSelfFillError` (40756) |
| The seller holds `amount` | `IdentityDoesNotHaveEnoughTokenBalanceError` |
| The token is not paused (to place or fill) | `TokenIsPausedError` |
| The seller's account is not frozen (to place or fill), nor the buyer's unless the token allows transfers to frozen balances | `IdentityTokenAccountFrozenError` |

The rules on a transition's own fields (the first two rows) are checked before anything is read and
refused unpaid. The state rules are paid: a refused transition bumps the signer's nonce and charges
the work that ran. The rules that read nothing beyond the contract (a tradeable token, its minimum
fill and what the listing is worth at its decimals) run with the batch's advanced structure, so
check_tx refuses them too. A
cancel has no rule beyond ownership, so an order can always be withdrawn, also when the token is paused, no longer tradeable or the owner's account is frozen. The buyer's credits are
checked with the batch's fees, which count a fill's cost like a direct purchase.

### Storage and proofs

The seller pays the storage of the `ask` and gets it back when the listing closes, by a fill that
takes everything left or by a cancel. A partial fill rewrites the `ask` without changing its size,
so the buyer pays no storage for it beyond their own token balance, except when a fill widens the
listing's `$revision` by a byte (first at revision 128, then at 16,384 and at every further 7
bits): that fill's buyer pays for the byte. The `ask` stays the seller's, so those bytes are
refunded to the seller when the listing closes. Selling a listing charges the seller nothing.

The proof a node returns for each transition:

| Transition | Proves |
|---|---|
| `TokenPlaceAsk` | The `ask` document, holding the listing's terms |
| `TokenFillAsk` | The buyer's token balance |
| `TokenCancelOrder` | That the order's document is gone |

Offers (`bid`) and their fills come with the next transitions of the marketplace. Until then no
offer can exist, and a `TokenCancelOrder` for one finds none.
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ object TokenMaterializer {

// Marketplace rules.
val marketplace = dict.obj("marketplaceRules")
val tradeMode = marketplace?.str("tradeMode") ?: "NotTradeable"
val tradeMode = marketplace?.tradeMode("tradeMode") ?: "NotTradeable"
val tradeModeChange = marketplace?.rule("tradeModeChangeRules")

// History-keeping: nested object or a single boolean for all.
Expand Down Expand Up @@ -208,6 +208,17 @@ object TokenMaterializer {
private fun JsonObject.rule(key: String): ChangeControlRules? =
(this[key] as? JsonObject)?.let(ChangeControlRules::parse)

/**
* rs-dpp writes `TokenTradeMode` externally tagged: `NotTradeable` as a
* bare string, and `Tradeable` (protocol version 15) as an object holding
* its fields, `{"Tradeable":{"minFill":1000}}`. The variant name, the
* string or the object's single key, is the mode the column keeps;
* `minFill` is not persisted. Port of `parseTokenTradeMode` in
* `DataContractParser.swift`.
*/
private fun JsonObject.tradeMode(key: String): String? =
str(key) ?: obj(key)?.keys?.singleOrNull()

/**
* u64-safe supply extraction — port of `extractTokenSupply`
* (numbers or strings; decimal string out).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.dashfoundation.example.services.tokens

import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity
import org.dashfoundation.dashsdk.persistence.entities.TokenEntity
import org.junit.Assert.assertEquals
import org.junit.Test

/**
* [TokenMaterializer] coverage for `marketplaceRules.tradeMode`. rs-dpp
* writes `TokenTradeMode` externally tagged: `NotTradeable` as a bare
* string, `Tradeable` (protocol version 15) as an object holding its
* `minFill` (a JSON number, or a decimal string above 2^53 - 1). The
* variant name lands in [TokenEntity.tradeMode]; `minFill` is not kept.
* Mirrors `DataContractParserTradeModeTests.swift`.
*/
class TokenMaterializerTradeModeTest {

private val contractId = ByteArray(32) { 0xAB.toByte() }

/** [tradeMode] is the raw JSON value of `marketplaceRules.tradeMode`. */
private fun parseSingleToken(tradeMode: String): TokenEntity {
val json = """{"tokens":{"0":{"baseSupply":0,"marketplaceRules":""" +
"""{"${'$'}formatVersion":"0","tradeMode":$tradeMode}}}}"""
val contract = DataContractEntity(
id = contractId,
name = "Fixture",
serializedContract = json.encodeToByteArray(),
networkRaw = 0,
)
val tokens = TokenMaterializer.parse(contract)
assertEquals(1, tokens.size)
return tokens.single()
}

@Test
fun `tradeable object form is stored as Tradeable`() {
val token = parseSingleToken("""{"Tradeable":{"minFill":1000}}""")

assertEquals("Tradeable", token.tradeMode)
}

@Test
fun `tradeable with a string minFill is stored as Tradeable`() {
val token = parseSingleToken("""{"Tradeable":{"minFill":"9223372036854775807"}}""")

assertEquals("Tradeable", token.tradeMode)
}

@Test
fun `bare NotTradeable string stays NotTradeable`() {
val token = parseSingleToken("\"NotTradeable\"")

assertEquals("NotTradeable", token.tradeMode)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ data class TokenEntity(
val mintingAllowChoosingDestination: Boolean = true,
/** JSON `TokenDistributionChangeRules` (UI-only). */
val distributionChangeRules: String? = null,
/** `TokenTradeMode.rawValue` ("NotTradeable"). */
/**
* `TokenTradeMode.rawValue`: "NotTradeable" or "Tradeable" (protocol
* version 15). Contract JSON carries `Tradeable` as an object holding its
* `minFill` (`{"Tradeable":{"minFill":1000}}`); only the variant name is
* kept, `minFill` is not persisted.
*/
val tradeMode: String = "NotTradeable",
val tradeModeChangeRules: String? = null,
val mainControlGroupPosition: Int? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use crate::balances::credits::TokenAmount;
use crate::consensus::basic::data_contract::{
NonTransferableTokenShieldedPoolError, NonTransferableTokenTradeableError,
TokenShieldedPoolIncompatibleRulesError,
};
use crate::consensus::basic::state_transition::StateTransitionNotActiveError;
use crate::consensus::basic::token::InvalidTokenOrderFillRangeError;
use crate::consensus::basic::unsupported_version_error::UnsupportedVersionError;
use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters;
use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters;
Expand Down Expand Up @@ -82,6 +84,12 @@ impl TokenConfiguration {
.is_tradeable()
}

/// The smallest fill of the token's orders (`marketplaceRules.tradeMode` `Tradeable`'s
/// `minFill`), `None` when the token is not tradeable.
pub fn min_fill(&self) -> Option<TokenAmount> {
self.as_cow_v0().marketplace_rules.trade_mode().min_fill()
}

/// Checks the configuration's format version against the bounds the platform version
/// admits (`dpp.contract_versions.token_versions.token_configuration_format`).
///
Expand Down Expand Up @@ -159,7 +167,8 @@ impl TokenConfiguration {
/// version 15 (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`): below it the mode is refused as
/// not active, the refusal software that predates the marketplace gives by failing to decode
/// it. A tradeable token must also be transferable, since filling a listing or an offer moves
/// the token from one identity to another. Checked on contract create and update.
/// the token from one identity to another, and its minimum fill is from 1 to `i64::MAX`
/// (`InvalidTokenOrderFillRangeError`). Checked on contract create and update.
pub fn validate_marketplace_rules(
&self,
token_contract_position: TokenContractPosition,
Expand Down Expand Up @@ -200,6 +209,16 @@ impl TokenConfiguration {
NonTransferableTokenTradeableError::new(token_contract_position).into(),
);
}
// An order stores its amounts as signed 8-byte values, so no order is larger than
// `i64::MAX`, and a minimum fill of 0 would let a fill take nothing
let largest_order = i64::MAX as TokenAmount;
if let Some(min_fill) = trade_mode.min_fill() {
if min_fill == 0 || min_fill > largest_order {
return SimpleConsensusValidationResult::new_with_error(
InvalidTokenOrderFillRangeError::new(min_fill, largest_order).into(),
);
}
}
SimpleConsensusValidationResult::new()
}

Expand Down Expand Up @@ -1221,7 +1240,7 @@ mod marketplace_rules_tests {
let mut configuration = TokenConfigurationV0::default_most_restrictive();
configuration
.marketplace_rules
.set_trade_mode(TokenTradeMode::Tradeable);
.set_trade_mode(TokenTradeMode::Tradeable { min_fill: 1 });
TokenConfiguration::V0(configuration)
}

Expand Down Expand Up @@ -1290,7 +1309,7 @@ mod marketplace_rules_tests {
configuration.set_transferable(false);

let proposed = configuration.validate_trade_mode(
&TokenTradeMode::Tradeable,
&TokenTradeMode::Tradeable { min_fill: 1 },
0,
PlatformVersion::latest(),
);
Expand All @@ -1302,12 +1321,44 @@ mod marketplace_rules_tests {
));

configuration.apply_token_configuration_item(
TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable),
TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable {
min_fill: 1,
}),
);
let applied = configuration.validate_marketplace_rules(0, PlatformVersion::latest());
assert_eq!(applied.errors, proposed.errors);
}

/// A tradeable token's minimum fill is from 1 to the largest order, `i64::MAX`.
#[test]
fn should_refuse_a_minimum_fill_of_0_or_above_the_largest_order() {
let largest_order = i64::MAX as TokenAmount;
for (min_fill, valid) in [
(0, false),
(1, true),
(largest_order, true),
(largest_order + 1, false),
] {
let result = tradeable().validate_trade_mode(
&TokenTradeMode::Tradeable { min_fill },
0,
PlatformVersion::latest(),
);
if valid {
assert!(result.is_valid(), "{min_fill}");
} else {
assert!(
matches!(
result.errors.as_slice(),
[ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))]
if error.min() == min_fill && error.max() == largest_order
),
"{min_fill}: {result:?}"
);
}
}
}

/// Proposing the mode every token may have is never refused.
#[test]
fn should_accept_proposing_not_tradeable_for_a_non_transferable_token() {
Expand Down
Loading
Loading