diff --git a/book/src/data-model/token-marketplace.md b/book/src/data-model/token-marketplace.md index 25d52b69cae..78fc3b63f6c 100644 --- a/book/src/data-model/token-marketplace.md +++ b/book/src/data-model/token-marketplace.md @@ -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" }, @@ -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 | @@ -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 @@ -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. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt index 552dd3311f1..6c791252846 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt @@ -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. @@ -208,6 +208,23 @@ object TokenMaterializer { private fun JsonObject.rule(key: String): ChangeControlRules? = (this[key] as? JsonObject)?.let(ChangeControlRules::parse) + /** The `TokenTradeMode` variants rs-dpp writes. */ + private val TRADE_MODES = setOf("NotTradeable", "Tradeable") + + /** + * 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. Any other name is refused (`null`, which + * the caller stores as `NotTradeable`), so a malformed or newer mode is + * never shown as tradeable. Port of `parseTokenTradeMode` in + * `DataContractParser.swift`, whose `TokenTradeMode(rawValue:)` refuses + * it the same way. + */ + private fun JsonObject.tradeMode(key: String): String? = + (str(key) ?: obj(key)?.keys?.singleOrNull())?.takeIf { it in TRADE_MODES } + /** * u64-safe supply extraction — port of `extractTokenSupply` * (numbers or strings; decimal string out). diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt new file mode 100644 index 00000000000..7fd500f55ce --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerTradeModeTest.kt @@ -0,0 +1,70 @@ +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. Any + * other name is stored as `NotTradeable`. + * 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) + } + + @Test + fun `an unknown bare mode is stored as NotTradeable`() { + val token = parseSingleToken("\"Auctioned\"") + + assertEquals("NotTradeable", token.tradeMode) + } + + @Test + fun `an unknown tagged mode is stored as NotTradeable`() { + val token = parseSingleToken("""{"Auctioned":{"minFill":1}}""") + + assertEquals("NotTradeable", token.tradeMode) + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt index 8ba49efbb96..903acf9dc87 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt @@ -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, diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs index f022ecc2f2c..669d115597e 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration/mod.rs @@ -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; @@ -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 { + 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`). /// @@ -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, @@ -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() } @@ -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) } @@ -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(), ); @@ -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() { diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs index b654adda583..fc1be75e373 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/mod.rs @@ -120,28 +120,35 @@ mod json_convertible_tests { } #[test] - fn should_serialize_tradeable_as_a_bare_string_on_both_wire_formats() { + fn should_serialize_tradeable_with_its_minimum_fill_on_both_wire_formats() { use crate::serialization::{JsonConvertible, ValueConvertible}; - let mut original = fixture(); - let TokenMarketplaceRules::V0(v0) = &mut original; - v0.trade_mode = TokenTradeMode::Tradeable; + for min_fill in [1000, u64::MAX] { + let mut original = fixture(); + let TokenMarketplaceRules::V0(v0) = &mut original; + v0.trade_mode = TokenTradeMode::Tradeable { min_fill }; - let json = original.to_json().expect("to_json"); - assert_eq!(json["tradeMode"], json!("Tradeable")); - assert_eq!( - TokenMarketplaceRules::from_json(json).expect("from_json"), - original - ); + let json = original.to_json().expect("to_json"); + // Past JavaScript's safe integers, JSON carries it as a string + let written = if min_fill > 9_007_199_254_740_991 { + json!(min_fill.to_string()) + } else { + json!(min_fill) + }; + assert_eq!( + json["tradeMode"], + json!({ "Tradeable": { "minFill": written } }) + ); + assert_eq!( + TokenMarketplaceRules::from_json(json).expect("from_json"), + original + ); - let value = original.to_object().expect("to_object"); - assert_eq!( - value.get_str("tradeMode").expect("expected a tradeMode"), - "Tradeable" - ); - assert_eq!( - TokenMarketplaceRules::from_object(value).expect("from_object"), - original - ); + let value = original.to_object().expect("to_object"); + assert_eq!( + TokenMarketplaceRules::from_object(value).expect("from_object"), + original + ); + } } /// `TokenTradeMode` is bincode-encoded positionally inside every stored token configuration @@ -154,6 +161,9 @@ mod json_convertible_tests { .expect("expected to encode the trade mode") }; assert_eq!(tag_of(TokenTradeMode::NotTradeable), vec![0]); - assert_eq!(tag_of(TokenTradeMode::Tradeable), vec![1]); + assert_eq!( + tag_of(TokenTradeMode::Tradeable { min_fill: 5 }), + vec![1, 5] + ); } } diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs index 370f9981d68..0d1b4012fc7 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_marketplace_rules/v0/mod.rs @@ -1,5 +1,6 @@ mod accessors; +use crate::balances::credits::TokenAmount; use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use crate::data_contract::change_control_rules::v0::ChangeControlRulesV0; use crate::data_contract::change_control_rules::ChangeControlRules; @@ -31,7 +32,19 @@ pub enum TokenTradeMode { /// credits are held in escrow until the order is filled or cancelled. Admitted from protocol /// version 15 (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`), and only on a transferable /// token, since every fill moves the token from one identity to another. - Tradeable, + Tradeable { + /// The smallest amount, in base units, a fill of a listing or an offer of the token + /// takes unless it takes everything the order still has open, and the least a fill + /// leaves open: so no order is left holding less than a trade. An order of less is + /// refused. Every fill reads it as the token has it when the fill executes, so a + /// change applies to the open orders too. From 1 to `i64::MAX`. + #[cfg_attr( + feature = "json-conversion", + serde(with = "crate::serialization::json_safe_u64") + )] + #[serde(rename = "minFill")] + min_fill: TokenAmount, + }, } impl TokenTradeMode { @@ -39,14 +52,23 @@ impl TokenTradeMode { pub fn is_tradeable(&self) -> bool { match self { TokenTradeMode::NotTradeable => false, - TokenTradeMode::Tradeable => true, + TokenTradeMode::Tradeable { .. } => true, + } + } + + /// The smallest fill of the token's orders, `None` when the token is not tradeable. + pub fn min_fill(&self) -> Option { + match self { + TokenTradeMode::NotTradeable => None, + TokenTradeMode::Tradeable { min_fill } => Some(*min_fill), } } } // Manual impls because TokenTradeMode is a flat enum (not versioned V0/V1). -// Unit-only enum — serde default emits the bare "NotTradeable" string on both -// wire formats (pinned by TokenMarketplaceRules' wire-shape tests). +// serde emits the bare "NotTradeable" string on both wire formats, and +// `{ "Tradeable": { "minFill": .. } }` for a tradeable token (pinned by +// TokenMarketplaceRules' wire-shape tests). #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] impl crate::serialization::JsonConvertible for TokenTradeMode {} diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index b21ad1a639d..896b18fb7c2 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -117,6 +117,9 @@ use crate::consensus::basic::state_transition::{ TransitionOverMaxInputsError, TransitionOverMaxOutputsError, WithdrawalBalanceMismatchError, WithdrawalBelowMinAmountError, }; +use crate::consensus::basic::token::{ + InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, InvalidTokenOrderValueError, +}; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, }; @@ -903,6 +906,18 @@ pub enum BasicError { #[error(transparent)] DuplicatePrivateDocumentEntryError(DuplicatePrivateDocumentEntryError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderPriceError(InvalidTokenOrderPriceError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderFillRangeError(InvalidTokenOrderFillRangeError), + + // A token order's structure (protocol version 15). + #[error(transparent)] + InvalidTokenOrderValueError(InvalidTokenOrderValueError), } impl From for ConsensusError { @@ -1149,8 +1164,7 @@ mod tests { )), 210 ); - // A `PrivateDocumentCreateFromShieldedPool`'s entries (protocol version 15): the tail - // of the enum. + // A `PrivateDocumentCreateFromShieldedPool`'s entries (protocol version 15). assert_eq!( discriminant_of(BasicError::InvalidPrivateDocumentEntryCountError( InvalidPrivateDocumentEntryCountError::new(17, 16) @@ -1169,6 +1183,27 @@ mod tests { )), 213 ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderPriceError( + InvalidTokenOrderPriceError::new(0, 1) + )), + 214 + ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderFillRangeError( + InvalidTokenOrderFillRangeError::new(0, 1) + )), + 215 + ); + // A token order's structure (protocol version 15). + assert_eq!( + discriminant_of(BasicError::InvalidTokenOrderValueError( + InvalidTokenOrderValueError::new(u64::MAX, u64::MAX, 0) + )), + 216 + ); } /// The variants that shipped in 4.1 keep the discriminants they were released with, so an diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs new file mode 100644 index 00000000000..964bbf647dd --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_fill_range_error.rs @@ -0,0 +1,59 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A token order's fill range is empty: a listing or an offer of less than its token's minimum +/// fill (`min` the minimum fill, `max` the order's amount), a tradeable token whose minimum fill is +/// 0 or above `i64::MAX` (`max`), or a fill whose minimum is 0 or above its maximum. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token order fill range is invalid: the minimum {min} must be at least 1 and at most {max}" +)] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderFillRangeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + min: TokenAmount, + max: TokenAmount, +} + +impl InvalidTokenOrderFillRangeError { + pub fn new(min: TokenAmount, max: TokenAmount) -> Self { + Self { min, max } + } + + pub fn min(&self) -> TokenAmount { + self.min + } + + pub fn max(&self) -> TokenAmount { + self.max + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderFillRangeError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderFillRangeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs new file mode 100644 index 00000000000..5785e7a8222 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_price_error.rs @@ -0,0 +1,56 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A token order's price is the credits per whole token: at least 1 credit, and at most what +/// an order's signed 8-byte fields hold. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order price {price} must be between 1 and {max_price} credits per whole token")] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderPriceError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + price: Credits, + max_price: Credits, +} + +impl InvalidTokenOrderPriceError { + pub fn new(price: Credits, max_price: Credits) -> Self { + Self { price, max_price } + } + + pub fn price(&self) -> Credits { + self.price + } + + pub fn max_price(&self) -> Credits { + self.max_price + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderPriceError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderPriceError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs new file mode 100644 index 00000000000..97c99efd3c4 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/token/invalid_token_order_value_error.rs @@ -0,0 +1,66 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// What a token order is worth in credits, its amount at its price and the decimals it signs, +/// does not fit in what an order can hold (`i64::MAX` credits). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A token order of {amount} base units at {price} credits per whole token ({decimals} decimals) is worth more credits than an order can hold")] +#[platform_serialize(unversioned)] +pub struct InvalidTokenOrderValueError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + amount: TokenAmount, + price: Credits, + decimals: u8, +} + +impl InvalidTokenOrderValueError { + pub fn new(amount: TokenAmount, price: Credits, decimals: u8) -> Self { + Self { + amount, + price, + decimals, + } + } + + pub fn amount(&self) -> TokenAmount { + self.amount + } + + pub fn price(&self) -> Credits { + self.price + } + + pub fn decimals(&self) -> u8 { + self.decimals + } +} + +impl From for ConsensusError { + fn from(err: InvalidTokenOrderValueError) -> Self { + Self::BasicError(BasicError::InvalidTokenOrderValueError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs index 249807c3af7..22a4a996b99 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/token/mod.rs @@ -35,3 +35,9 @@ pub use missing_default_localization::*; pub use token_note_only_allowed_on_proposer_error::*; pub use token_pricing_schedule_empty_error::*; pub use token_transfer_to_ourselves_error::*; +mod invalid_token_order_price_error; +pub use invalid_token_order_price_error::*; +mod invalid_token_order_fill_range_error; +pub use invalid_token_order_fill_range_error::*; +mod invalid_token_order_value_error; +pub use invalid_token_order_value_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 9b3326f5a4c..67b40bd88ef 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -127,6 +127,9 @@ impl ErrorWithCode for BasicError { Self::NonTransferableTokenPaymentMustBurnError(_) => 10280, Self::NonTransferableTokenTradeableError(_) => 10281, Self::DataContractCompiledMaxSizeExceededError(_) => 10282, + Self::InvalidTokenOrderPriceError(_) => 10462, + Self::InvalidTokenOrderFillRangeError(_) => 10463, + Self::InvalidTokenOrderValueError(_) => 10465, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, @@ -400,6 +403,13 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentTypePrivateError(_) => 40150, Self::DocumentTypeNotPrivateError(_) => 40151, Self::PrivateDocumentEntrySizeMismatchError(_) => 40152, + Self::TokenNotTradeableError(_) => 40750, + Self::TokenOrderNotFoundError(_) => 40751, + Self::TokenOrderFillAmountError(_) => 40753, + Self::TokenOrderPriceLimitError(_) => 40754, + Self::TokenOrderNotOwnedError(_) => 40755, + Self::TokenOrderSelfFillError(_) => 40756, + Self::TokenOrderDecimalsMismatchError(_) => 40758, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 2d07f7c7f9d..4ae12c7ad21 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -5,6 +5,7 @@ use platform_serialization_derive::{ }; use thiserror::Error; +use crate::consensus::state::token::{TokenNotTradeableError, TokenOrderNotFoundError, TokenOrderFillAmountError, TokenOrderPriceLimitError, TokenOrderNotOwnedError, TokenOrderSelfFillError, TokenOrderDecimalsMismatchError}; use crate::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use crate::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use crate::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -771,6 +772,34 @@ pub enum StateError { #[error(transparent)] PrivateDocumentEntrySizeMismatchError(PrivateDocumentEntrySizeMismatchError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenNotTradeableError(TokenNotTradeableError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderNotFoundError(TokenOrderNotFoundError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderFillAmountError(TokenOrderFillAmountError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderPriceLimitError(TokenOrderPriceLimitError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderNotOwnedError(TokenOrderNotOwnedError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderSelfFillError(TokenOrderSelfFillError), + + // The token marketplace (protocol version 15). + #[error(transparent)] + TokenOrderDecimalsMismatchError(TokenOrderDecimalsMismatchError), } impl From for ConsensusError { @@ -798,6 +827,7 @@ mod tests { LookupKeySource, }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; + use crate::tokens::token_order_side::TokenOrderSide; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; @@ -1731,8 +1761,7 @@ mod tests { )), 178 ); - // A `PrivateDocumentCreateFromShieldedPool`'s target (protocol version 15): the tail of - // the enum. + // A `PrivateDocumentCreateFromShieldedPool`'s target (protocol version 15). assert_eq!( discriminant_of(StateError::DocumentTypeNotPrivateError( DocumentTypeNotPrivateError::new(identity_id, "contactRequest".to_string()) @@ -1750,5 +1779,58 @@ mod tests { )), 180 ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenNotTradeableError( + TokenNotTradeableError::new(Identifier::from([1; 32])) + )), + 181 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderNotFoundError( + TokenOrderNotFoundError::new(Identifier::from([1; 32]), TokenOrderSide::Ask) + )), + 182 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderFillAmountError( + TokenOrderFillAmountError::new(Identifier::from([1; 32]), 1, 2, 3, 4) + )), + 183 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderPriceLimitError( + TokenOrderPriceLimitError::new(Identifier::from([1; 32]), 2, 1) + )), + 184 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderNotOwnedError( + TokenOrderNotOwnedError::new( + Identifier::from([1; 32]), + Identifier::from([1; 32]), + Identifier::from([1; 32]) + ) + )), + 185 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderSelfFillError( + TokenOrderSelfFillError::new(Identifier::from([1; 32]), Identifier::from([1; 32])) + )), + 186 + ); + // The token marketplace (protocol version 15). + assert_eq!( + discriminant_of(StateError::TokenOrderDecimalsMismatchError( + TokenOrderDecimalsMismatchError::new(Identifier::from([1; 32]), 8, 2) + )), + 187 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs index 71a94a6d7fb..19de76a741b 100644 --- a/packages/rs-dpp/src/errors/consensus/state/token/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/token/mod.rs @@ -67,3 +67,17 @@ pub use token_shielded_payment_not_required_error::*; pub use token_shielded_pool_not_enabled_error::*; pub use token_transfer_recipient_identity_not_exist_error::*; pub use unauthorized_token_action_error::*; +mod token_not_tradeable_error; +pub use token_not_tradeable_error::*; +mod token_order_not_found_error; +pub use token_order_not_found_error::*; +mod token_order_fill_amount_error; +pub use token_order_fill_amount_error::*; +mod token_order_price_limit_error; +pub use token_order_price_limit_error::*; +mod token_order_not_owned_error; +pub use token_order_not_owned_error::*; +mod token_order_self_fill_error; +pub use token_order_self_fill_error::*; +mod token_order_decimals_mismatch_error; +pub use token_order_decimals_mismatch_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs new file mode 100644 index 00000000000..84ebbe213cd --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_not_tradeable_error.rs @@ -0,0 +1,51 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// The token's marketplace trade mode is not `Tradeable`, so it cannot be listed or offered for, +/// and its open orders cannot be filled (they can still be cancelled). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token {token_id} is not tradeable")] +#[platform_serialize(unversioned)] +pub struct TokenNotTradeableError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, +} + +impl TokenNotTradeableError { + pub fn new(token_id: Identifier) -> Self { + Self { token_id } + } + + pub fn token_id(&self) -> Identifier { + self.token_id + } +} + +impl From for ConsensusError { + fn from(err: TokenNotTradeableError) -> Self { + Self::StateError(StateError::TokenNotTradeableError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs new file mode 100644 index 00000000000..35bd4efb86e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_decimals_mismatch_error.rs @@ -0,0 +1,66 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A token order was signed at a price scale (the token's decimals) other than the token's when +/// it executes. An order's price is per whole token, so the decimals set what it is worth: the +/// order is refused rather than priced at a scale its signer did not sign. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A token order for token {token_id} was signed at {signed_decimals} decimals, but the token has {token_decimals}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderDecimalsMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_id: Identifier, + signed_decimals: u8, + token_decimals: u8, +} + +impl TokenOrderDecimalsMismatchError { + pub fn new(token_id: Identifier, signed_decimals: u8, token_decimals: u8) -> Self { + Self { + token_id, + signed_decimals, + token_decimals, + } + } + + pub fn token_id(&self) -> Identifier { + self.token_id + } + + pub fn signed_decimals(&self) -> u8 { + self.signed_decimals + } + + pub fn token_decimals(&self) -> u8 { + self.token_decimals + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderDecimalsMismatchError) -> Self { + Self::StateError(StateError::TokenOrderDecimalsMismatchError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs new file mode 100644 index 00000000000..a01b8e7ce61 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_fill_amount_error.rs @@ -0,0 +1,85 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// No amount between the fill's minimum and maximum can be taken from the order: less is open than +/// the fill's minimum, or the amount would be under the order's minimum fill without taking +/// everything open, or would leave less than the minimum fill open. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("A fill of token order {order_id} between {min_amount} and {max_amount} cannot be taken: {remaining} is open, and a fill takes at least {min_fill} and leaves none or at least {min_fill}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderFillAmountError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + remaining: TokenAmount, + min_fill: TokenAmount, +} + +impl TokenOrderFillAmountError { + pub fn new( + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + remaining: TokenAmount, + min_fill: TokenAmount, + ) -> Self { + Self { + order_id, + min_amount, + max_amount, + remaining, + min_fill, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn min_amount(&self) -> TokenAmount { + self.min_amount + } + + pub fn max_amount(&self) -> TokenAmount { + self.max_amount + } + + pub fn remaining(&self) -> TokenAmount { + self.remaining + } + + pub fn min_fill(&self) -> TokenAmount { + self.min_fill + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderFillAmountError) -> Self { + Self::StateError(StateError::TokenOrderFillAmountError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs new file mode 100644 index 00000000000..2da706c31a2 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_found_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// No open order of the side has this id for the token: it never existed, was filled entirely or +/// was cancelled. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order {order_id} ({side}) was not found")] +#[platform_serialize(unversioned)] +pub struct TokenOrderNotFoundError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + side: TokenOrderSide, +} + +impl TokenOrderNotFoundError { + pub fn new(order_id: Identifier, side: TokenOrderSide) -> Self { + Self { order_id, side } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn side(&self) -> TokenOrderSide { + self.side + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderNotFoundError) -> Self { + Self::StateError(StateError::TokenOrderNotFoundError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs new file mode 100644 index 00000000000..ebb25eae6e6 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_not_owned_error.rs @@ -0,0 +1,64 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// Only an order's maker can cancel it. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Token order {order_id} belongs to {owner_id}, not {sender_id}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderNotOwnedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + owner_id: Identifier, + sender_id: Identifier, +} + +impl TokenOrderNotOwnedError { + pub fn new(order_id: Identifier, owner_id: Identifier, sender_id: Identifier) -> Self { + Self { + order_id, + owner_id, + sender_id, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn owner_id(&self) -> Identifier { + self.owner_id + } + + pub fn sender_id(&self) -> Identifier { + self.sender_id + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderNotOwnedError) -> Self { + Self::StateError(StateError::TokenOrderNotOwnedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs new file mode 100644 index 00000000000..84bcce5da1d --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_price_limit_error.rs @@ -0,0 +1,68 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// The credits a fill moves are past the limit the filler set: more than its maximum for a +/// purchase, less than its minimum for a sale. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The fill of token order {order_id} moves {total} credits, past the filler's limit of {limit}" +)] +#[platform_serialize(unversioned)] +pub struct TokenOrderPriceLimitError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + total: Credits, + limit: Credits, +} + +impl TokenOrderPriceLimitError { + pub fn new(order_id: Identifier, total: Credits, limit: Credits) -> Self { + Self { + order_id, + total, + limit, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn total(&self) -> Credits { + self.total + } + + pub fn limit(&self) -> Credits { + self.limit + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderPriceLimitError) -> Self { + Self::StateError(StateError::TokenOrderPriceLimitError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs b/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs new file mode 100644 index 00000000000..73dab0f585e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/token/token_order_self_fill_error.rs @@ -0,0 +1,58 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// An identity cannot fill its own order: it cancels it instead. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Identity {identity_id} cannot fill its own token order {order_id}")] +#[platform_serialize(unversioned)] +pub struct TokenOrderSelfFillError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + order_id: Identifier, + identity_id: Identifier, +} + +impl TokenOrderSelfFillError { + pub fn new(order_id: Identifier, identity_id: Identifier) -> Self { + Self { + order_id, + identity_id, + } + } + + pub fn order_id(&self) -> Identifier { + self.order_id + } + + pub fn identity_id(&self) -> Identifier { + self.identity_id + } +} + +impl From for ConsensusError { + fn from(err: TokenOrderSelfFillError) -> Self { + Self::StateError(StateError::TokenOrderSelfFillError(err)) + } +} diff --git a/packages/rs-dpp/src/serialization/json/safe_fields.rs b/packages/rs-dpp/src/serialization/json/safe_fields.rs index 0ffbbea9929..f9a92394e34 100644 --- a/packages/rs-dpp/src/serialization/json/safe_fields.rs +++ b/packages/rs-dpp/src/serialization/json/safe_fields.rs @@ -14,6 +14,7 @@ use crate::state_transition::batch_transition::document_base_transition::Documen #[cfg(feature = "state-transitions")] use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use crate::tokens::emergency_action::TokenEmergencyAction; +use crate::tokens::token_order_side::TokenOrderSide; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::tokens::token_payment_info::TokenPaymentInfo; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -164,6 +165,7 @@ impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} impl JsonSafeFields for crate::tokens::token_event::TokenEvent {} // `TokenEmergencyAction` is a unit-variant enum (Pause / Resume). impl JsonSafeFields for TokenEmergencyAction {} +impl JsonSafeFields for TokenOrderSide {} // `TokenDistributionType` is a unit-variant enum. impl JsonSafeFields for TokenDistributionType {} // `TokenPricingSchedule` has tuple variants holding `Credits` (u64) and diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 40d9e10e722..24d058a3725 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -801,7 +801,10 @@ fn outputs_only_bundle_tag_v0(action_type: TokenTransitionActionType) -> Result< | TokenTransitionActionType::SetPriceForDirectPurchase | TokenTransitionActionType::BurnFromPool | TokenTransitionActionType::Unshield - | TokenTransitionActionType::ShieldedTransfer) => { + | TokenTransitionActionType::ShieldedTransfer + | TokenTransitionActionType::PlaceAsk + | TokenTransitionActionType::FillAsk + | TokenTransitionActionType::CancelOrder) => { Err(ProtocolError::InvalidStateTransitionType(format!( "{other} is not an outputs-only token pool transition and has no bundle tag" ))) diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index df55059a7b5..6c07f1cbcd8 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1442,6 +1442,13 @@ impl StateTransition { BatchedTransitionRef::Token(TokenTransition::DirectPurchaseToPool(_)) => { "TokenDirectPurchaseToPool" } + BatchedTransitionRef::Token(TokenTransition::PlaceAsk(_)) => { + "TokenPlaceAsk" + } + BatchedTransitionRef::Token(TokenTransition::FillAsk(_)) => "TokenFillAsk", + BatchedTransitionRef::Token(TokenTransition::CancelOrder(_)) => { + "TokenCancelOrder" + } }; document_transition_types.push(document_transition_name); } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs index 3f193f9b005..0cd46ddfcbf 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs @@ -18,6 +18,7 @@ mod resolvers; pub mod token_base_transition; pub mod token_burn_from_pool_transition; pub mod token_burn_transition; +pub mod token_cancel_order_transition; pub mod token_claim_to_pool_transition; pub mod token_claim_transition; pub mod token_config_update_transition; @@ -25,9 +26,12 @@ pub mod token_destroy_frozen_funds_transition; pub mod token_direct_purchase_to_pool_transition; pub mod token_direct_purchase_transition; pub mod token_emergency_action_transition; +pub mod token_fill_ask_transition; pub mod token_freeze_transition; pub mod token_mint_to_pool_transition; pub mod token_mint_transition; +pub mod token_order_terms; +pub mod token_place_ask_transition; pub mod token_set_price_for_direct_purchase_transition; pub mod token_shield_transition; pub mod token_shielded_transfer_transition; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs new file mode 100644 index 00000000000..ff68d98f212 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenCancelOrderTransitionV0; + +/// A versioned `TokenCancelOrderTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenCancelOrderTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenCancelOrderTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenCancelOrderTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenCancelOrderTransition {} + +impl Default for TokenCancelOrderTransition { + fn default() -> Self { + TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs new file mode 100644 index 00000000000..40807885dc3 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/mod.rs @@ -0,0 +1,42 @@ +pub mod v0_methods; + +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::tokens::token_order_side::TokenOrderSide; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenCancelOrderTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Cancels the signer's own open order `order_id`: what it still holds in escrow goes back to +/// the signer and the order is deleted. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenCancelOrderTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The order to cancel. + pub order_id: Identifier, + /// Whether the order is a listing or an offer. + pub side: TokenOrderSide, +} + +impl fmt::Display for TokenCancelOrderTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token CancelOrder, base: {}, order: {}, side: {}", + self.base, self.order_id, self.side + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..9ef197eb454 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0/v0_methods.rs @@ -0,0 +1,47 @@ +use crate::tokens::token_order_side::TokenOrderSide; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; + +impl TokenBaseTransitionAccessors for TokenCancelOrderTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenCancelOrderTransitionV0Methods: TokenBaseTransitionAccessors { + fn order_id(&self) -> Identifier; + + fn set_order_id(&mut self, order_id: Identifier); + + fn side(&self) -> TokenOrderSide; + + fn set_side(&mut self, side: TokenOrderSide); +} + +impl TokenCancelOrderTransitionV0Methods for TokenCancelOrderTransitionV0 { + fn order_id(&self) -> Identifier { + self.order_id + } + + fn set_order_id(&mut self, order_id: Identifier) { + self.order_id = order_id; + } + + fn side(&self) -> TokenOrderSide { + self.side + } + + fn set_side(&mut self, side: TokenOrderSide) { + self.side = side; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs new file mode 100644 index 00000000000..f07ca598bdd --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/v0_methods.rs @@ -0,0 +1,52 @@ +use crate::tokens::token_order_side::TokenOrderSide; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use crate::state_transition::batch_transition::TokenCancelOrderTransition; + +impl TokenBaseTransitionAccessors for TokenCancelOrderTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenCancelOrderTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenCancelOrderTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenCancelOrderTransitionV0Methods for TokenCancelOrderTransition { + fn order_id(&self) -> Identifier { + match self { + TokenCancelOrderTransition::V0(v0) => v0.order_id(), + } + } + + fn set_order_id(&mut self, order_id: Identifier) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.set_order_id(order_id), + } + } + + fn side(&self) -> TokenOrderSide { + match self { + TokenCancelOrderTransition::V0(v0) => v0.side(), + } + } + + fn set_side(&mut self, side: TokenOrderSide) { + match self { + TokenCancelOrderTransition::V0(v0) => v0.set_side(side), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..d679a528a00 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/mod.rs @@ -0,0 +1,36 @@ +use crate::state_transition::batch_transition::token_cancel_order_transition::validate_structure::v0::TokenCancelOrderTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenCancelOrderTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenCancelOrderTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionStructureValidation for TokenCancelOrderTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_cancel_order_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenCancelOrderTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..b2793143d0e --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_cancel_order_transition/validate_structure/v0/mod.rs @@ -0,0 +1,14 @@ +use crate::state_transition::batch_transition::TokenCancelOrderTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait TokenCancelOrderTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenCancelOrderTransitionStructureValidationV0 for TokenCancelOrderTransition { + /// A cancel names an order and its side, which state validation finds and checks. + fn validate_structure_v0(&self) -> Result { + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs new file mode 100644 index 00000000000..479d2fccd27 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenFillAskTransitionV0; + +/// A versioned `TokenFillAskTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenFillAskTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenFillAskTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenFillAskTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenFillAskTransition {} + +impl Default for TokenFillAskTransition { + fn default() -> Self { + TokenFillAskTransition::V0(TokenFillAskTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs new file mode 100644 index 00000000000..909c488f8da --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/mod.rs @@ -0,0 +1,48 @@ +pub mod v0_methods; + +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenFillAskTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Buys from the open listing `order_id`: everything still open, up to `max_amount`, and at +/// least `min_amount`. The buyer pays the listing's price for what the fill takes, refused when +/// that is above `max_total_price`; the tokens move from escrow to the buyer. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenFillAskTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The listing to buy from. + pub order_id: Identifier, + /// The least amount, in base units, the buyer accepts; less still open refuses the fill. + pub min_amount: TokenAmount, + /// The most amount, in base units, the buyer takes. + pub max_amount: TokenAmount, + /// The most credits the buyer pays for the fill. + pub max_total_price: Credits, +} + +impl fmt::Display for TokenFillAskTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token FillAsk, base: {}, order: {}, min amount: {}, max amount: {}, max total price: {}", + self.base, self.order_id, self.min_amount, self.max_amount, self.max_total_price + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..28331e0c030 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0/v0_methods.rs @@ -0,0 +1,72 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; + +impl TokenBaseTransitionAccessors for TokenFillAskTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenFillAskTransitionV0Methods: TokenBaseTransitionAccessors { + fn order_id(&self) -> Identifier; + + fn set_order_id(&mut self, order_id: Identifier); + + fn min_amount(&self) -> TokenAmount; + + fn set_min_amount(&mut self, min_amount: TokenAmount); + + fn max_amount(&self) -> TokenAmount; + + fn set_max_amount(&mut self, max_amount: TokenAmount); + + fn max_total_price(&self) -> Credits; + + fn set_max_total_price(&mut self, max_total_price: Credits); +} + +impl TokenFillAskTransitionV0Methods for TokenFillAskTransitionV0 { + fn order_id(&self) -> Identifier { + self.order_id + } + + fn set_order_id(&mut self, order_id: Identifier) { + self.order_id = order_id; + } + + fn min_amount(&self) -> TokenAmount { + self.min_amount + } + + fn set_min_amount(&mut self, min_amount: TokenAmount) { + self.min_amount = min_amount; + } + + fn max_amount(&self) -> TokenAmount { + self.max_amount + } + + fn set_max_amount(&mut self, max_amount: TokenAmount) { + self.max_amount = max_amount; + } + + fn max_total_price(&self) -> Credits { + self.max_total_price + } + + fn set_max_total_price(&mut self, max_total_price: Credits) { + self.max_total_price = max_total_price; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs new file mode 100644 index 00000000000..1a209067729 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/v0_methods.rs @@ -0,0 +1,77 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use platform_value::Identifier; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenFillAskTransition; + +impl TokenBaseTransitionAccessors for TokenFillAskTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenFillAskTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenFillAskTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenFillAskTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenFillAskTransitionV0Methods for TokenFillAskTransition { + fn order_id(&self) -> Identifier { + match self { + TokenFillAskTransition::V0(v0) => v0.order_id(), + } + } + + fn set_order_id(&mut self, order_id: Identifier) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_order_id(order_id), + } + } + + fn min_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransition::V0(v0) => v0.min_amount(), + } + } + + fn set_min_amount(&mut self, min_amount: TokenAmount) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_min_amount(min_amount), + } + } + + fn max_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransition::V0(v0) => v0.max_amount(), + } + } + + fn set_max_amount(&mut self, max_amount: TokenAmount) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_max_amount(max_amount), + } + } + + fn max_total_price(&self) -> Credits { + match self { + TokenFillAskTransition::V0(v0) => v0.max_total_price(), + } + } + + fn set_max_total_price(&mut self, max_total_price: Credits) { + match self { + TokenFillAskTransition::V0(v0) => v0.set_max_total_price(max_total_price), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..47f26f8de7a --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/mod.rs @@ -0,0 +1,36 @@ +use crate::state_transition::batch_transition::token_fill_ask_transition::validate_structure::v0::TokenFillAskTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenFillAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenFillAskTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionStructureValidation for TokenFillAskTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenFillAskTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..8d321672984 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_fill_ask_transition/validate_structure/v0/mod.rs @@ -0,0 +1,24 @@ +use crate::consensus::basic::token::InvalidTokenOrderFillRangeError; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenFillAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait TokenFillAskTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenFillAskTransitionStructureValidationV0 for TokenFillAskTransition { + /// The fill's range is not empty: 1 <= `min_amount` <= `max_amount` <= `i64::MAX`. + fn validate_structure_v0(&self) -> Result { + if self.min_amount() == 0 + || self.min_amount() > self.max_amount() + || self.max_amount() > i64::MAX as u64 + { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(self.min_amount(), self.max_amount()).into(), + )); + } + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs new file mode 100644 index 00000000000..5a298957a71 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_order_terms.rs @@ -0,0 +1,105 @@ +//! The terms of a new token order (protocol version 15), checked alike for a listing +//! (`TokenPlaceAsk`) and an offer (`TokenPlaceBid`). + +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::token::{ + InvalidTokenAmountError, InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, + InvalidTokenOrderValueError, +}; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::fee::Credits; +use crate::tokens::token_order::TokenOrder; +use crate::validation::SimpleConsensusValidationResult; + +/// The largest amount and price an order may carry: its document stores each as a signed +/// 8-byte value, and its value is computed from the amount and the price in u128. +pub(crate) const MAX_TOKEN_ORDER_TERM: u64 = i64::MAX as u64; + +/// The amount and the price are between 1 and `i64::MAX`. What the order needs of the token, its +/// minimum fill and its decimals, is checked with the batch's advanced structure, which knows the +/// token ([`validate_token_order_terms_for_token`]). +pub(crate) fn validate_token_order_terms( + amount: TokenAmount, + price: Credits, +) -> SimpleConsensusValidationResult { + if amount == 0 || amount > MAX_TOKEN_ORDER_TERM { + return SimpleConsensusValidationResult::new_with_error(ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(InvalidTokenAmountError::new( + MAX_TOKEN_ORDER_TERM, + amount, + )), + )); + } + if price == 0 || price > MAX_TOKEN_ORDER_TERM { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderPriceError::new(price, MAX_TOKEN_ORDER_TERM).into(), + ); + } + SimpleConsensusValidationResult::default() +} + +/// What an order of `amount` at `price` needs of its token, given the token's minimum fill and +/// decimals as the token has them when the order is placed: an amount of at least the minimum +/// fill (`InvalidTokenOrderFillRangeError`, the minimum and the amount), so no order opens +/// holding less than a trade, and a worth at the token's decimals (`TokenOrder::order_value`) of +/// at most `i64::MAX` credits (`InvalidTokenOrderValueError`). +pub fn validate_token_order_terms_for_token( + amount: TokenAmount, + price: Credits, + min_fill: TokenAmount, + decimals: u8, +) -> SimpleConsensusValidationResult { + if amount < min_fill { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderFillRangeError::new(min_fill, amount).into(), + ); + } + if TokenOrder::order_value(amount, price, decimals).is_none() { + return SimpleConsensusValidationResult::new_with_error( + InvalidTokenOrderValueError::new(amount, price, decimals).into(), + ); + } + SimpleConsensusValidationResult::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An order is worth its amount at its price at the token's decimals, at most `i64::MAX` + /// credits: 1 base unit at `i64::MAX` credits per whole token is worth exactly that at 0 + /// decimals, 2 are worth more; at 8 decimals 2 are worth far less, and from 39 decimals + /// any order is worth one credit. + #[test] + fn should_refuse_an_order_worth_more_than_an_order_holds() { + let at = |amount, decimals| { + validate_token_order_terms_for_token(amount, MAX_TOKEN_ORDER_TERM, 1, decimals).errors + }; + assert!(at(1, 0).is_empty()); + assert!(at(2, 8).is_empty()); + assert!(at(MAX_TOKEN_ORDER_TERM, 39).is_empty()); + assert!(at(MAX_TOKEN_ORDER_TERM, 255).is_empty()); + assert!(matches!( + at(2, 0).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] + if error.amount() == 2 + && error.price() == MAX_TOKEN_ORDER_TERM + && error.decimals() == 0 + )); + } + + /// An order of less than the token's minimum fill is refused; one of exactly it, or more, + /// is not. + #[test] + fn should_refuse_an_order_of_less_than_the_minimum_fill() { + let at = |amount| validate_token_order_terms_for_token(amount, 1, 100, 2).errors; + assert!(at(100).is_empty()); + assert!(at(1_000).is_empty()); + assert!(matches!( + at(99).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))] + if error.min() == 100 && error.max() == 99 + )); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs new file mode 100644 index 00000000000..c705e18ccbb --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/mod.rs @@ -0,0 +1,38 @@ +pub mod v0; +mod v0_methods; +pub mod validate_structure; + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::{Display, From}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +pub use v0::TokenPlaceAskTransitionV0; + +/// A versioned `TokenPlaceAskTransition` (protocol version 15, the token marketplace). +#[derive(Debug, Clone, Encode, Decode, PartialEq, Display, From, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +pub enum TokenPlaceAskTransition { + #[display("V0({})", "_0")] + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(TokenPlaceAskTransitionV0), +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenPlaceAskTransition {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenPlaceAskTransition {} + +impl Default for TokenPlaceAskTransition { + fn default() -> Self { + TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0::default()) // since only v0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs new file mode 100644 index 00000000000..5a9d4c780f4 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/mod.rs @@ -0,0 +1,46 @@ +pub mod v0_methods; + +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use bincode::{Decode, DecodeUntrusted, Encode}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The Identifier fields in [`TokenPlaceAskTransitionV0`] +pub use super::super::document_base_transition::IDENTIFIER_FIELDS; + +/// Lists `amount` of a tradeable token for sale at `price` credits per whole token. The amount +/// moves from the seller's balance into escrow, where it stays until the listing is filled or +/// cancelled. +#[derive(Debug, Clone, Default, Encode, Decode, PartialEq, DecodeUntrusted)] +// Auto-injects `json_safe_u64` on the u64 fields. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct TokenPlaceAskTransitionV0 { + /// Document Base Transition + #[cfg_attr(feature = "serde-conversion", serde(flatten))] + pub base: TokenBaseTransition, + /// The token amount listed, in base units. + pub amount: TokenAmount, + /// The credits asked per whole token: 10 to the power of the token's decimals base units, as + /// the token has them when the listing is placed. The listing records them and its fills + /// are priced at them; the token's minimum fill (`TokenTradeMode::Tradeable`) bounds how + /// little a fill takes. + pub price: Credits, +} + +impl fmt::Display for TokenPlaceAskTransitionV0 { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Token PlaceAsk, base: {}, amount: {}, price: {}", + self.base, self.amount, self.price + ) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..ddc4deba9ce --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0/v0_methods.rs @@ -0,0 +1,47 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; + +impl TokenBaseTransitionAccessors for TokenPlaceAskTransitionV0 { + fn base(&self) -> &TokenBaseTransition { + &self.base + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + &mut self.base + } + + fn set_base(&mut self, base: TokenBaseTransition) { + self.base = base; + } +} + +pub trait TokenPlaceAskTransitionV0Methods: TokenBaseTransitionAccessors { + fn amount(&self) -> TokenAmount; + + fn set_amount(&mut self, amount: TokenAmount); + + fn price(&self) -> Credits; + + fn set_price(&mut self, price: Credits); +} + +impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransitionV0 { + fn amount(&self) -> TokenAmount { + self.amount + } + + fn set_amount(&mut self, amount: TokenAmount) { + self.amount = amount; + } + + fn price(&self) -> Credits { + self.price + } + + fn set_price(&mut self, price: Credits) { + self.price = price; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs new file mode 100644 index 00000000000..5ba2c38ef51 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/v0_methods.rs @@ -0,0 +1,52 @@ +use crate::balances::credits::TokenAmount; +use crate::fee::Credits; +use crate::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; + +impl TokenBaseTransitionAccessors for TokenPlaceAskTransition { + fn base(&self) -> &TokenBaseTransition { + match self { + TokenPlaceAskTransition::V0(v0) => &v0.base, + } + } + + fn base_mut(&mut self) -> &mut TokenBaseTransition { + match self { + TokenPlaceAskTransition::V0(v0) => &mut v0.base, + } + } + + fn set_base(&mut self, base: TokenBaseTransition) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.base = base, + } + } +} + +impl TokenPlaceAskTransitionV0Methods for TokenPlaceAskTransition { + fn amount(&self) -> TokenAmount { + match self { + TokenPlaceAskTransition::V0(v0) => v0.amount(), + } + } + + fn set_amount(&mut self, amount: TokenAmount) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_amount(amount), + } + } + + fn price(&self) -> Credits { + match self { + TokenPlaceAskTransition::V0(v0) => v0.price(), + } + } + + fn set_price(&mut self, price: Credits) { + match self { + TokenPlaceAskTransition::V0(v0) => v0.set_price(price), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..0eb3442000e --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/mod.rs @@ -0,0 +1,98 @@ +use crate::state_transition::batch_transition::token_place_ask_transition::validate_structure::v0::TokenPlaceAskTransitionStructureValidationV0; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub trait TokenPlaceAskTransitionStructureValidation { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionStructureValidation for TokenPlaceAskTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenPlaceAskTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; + + const SIGNED_MAX: u64 = i64::MAX as u64; + + fn errors(amount: u64, price: u64) -> Vec { + TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { + base: Default::default(), + amount, + price, + }) + .validate_structure(PlatformVersion::latest()) + .expect("expected the structure validation to run") + .errors + } + + /// A listing's amount and price are stored as signed 8-byte values, so each is 1 to + /// `i64::MAX`. At 8 decimals, `i64::MAX + 1` base units at 1 credit and 1 base unit at + /// `i64::MAX + 1` credits are both worth what an order holds, so only these bounds refuse + /// them before they reach the order's document. What the listing is worth at the token's + /// decimals is checked with the batch's advanced structure, which knows the token. + #[test] + fn should_refuse_an_amount_or_price_outside_a_signed_8_byte_value() { + for (term, accepted) in [ + (0, false), + (1, true), + (SIGNED_MAX, true), + (SIGNED_MAX + 1, false), + ] { + let by_amount = errors(term, 1); + let by_price = errors(1, term); + if accepted { + assert!(by_amount.is_empty(), "amount {term}: {by_amount:?}"); + assert!(by_price.is_empty(), "price {term}: {by_price:?}"); + } else { + assert!( + matches!( + by_amount.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(_) + )] + ), + "amount {term}: {by_amount:?}" + ); + assert!( + matches!( + by_price.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenOrderPriceError(_) + )] + ), + "price {term}: {by_price:?}" + ); + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..9ec7d92328b --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_place_ask_transition/validate_structure/v0/mod.rs @@ -0,0 +1,16 @@ +use crate::state_transition::batch_transition::batched_transition::token_order_terms::validate_token_order_terms; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::TokenPlaceAskTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait TokenPlaceAskTransitionStructureValidationV0 { + fn validate_structure_v0(&self) -> Result; +} + +impl TokenPlaceAskTransitionStructureValidationV0 for TokenPlaceAskTransition { + /// The listing's terms, checked as an offer's are: see `validate_token_order_terms`. + fn validate_structure_v0(&self) -> Result { + Ok(validate_token_order_terms(self.amount(), self.price())) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs index 6e31dd1f946..bf6d5ebe960 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs @@ -21,7 +21,8 @@ use crate::prelude::IdentityNonce; use crate::ProtocolError; use crate::state_transition::batch_transition::{DocumentCreateTransition, DocumentDeleteTransition, DocumentReplaceTransition, TokenBurnTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintTransition, TokenClaimTransition, TokenTransferTransition, TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, TokenShieldedTransferTransition, TokenUnshieldTransition, TokenMintToPoolTransition, TokenBurnFromPoolTransition, TokenClaimToPoolTransition, - TokenDirectPurchaseToPoolTransition, + TokenDirectPurchaseToPoolTransition, TokenPlaceAskTransition, TokenFillAskTransition, + TokenCancelOrderTransition, }; #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] use crate::serialization::JsonConvertible; @@ -55,6 +56,10 @@ use crate::state_transition::batch_transition::token_direct_purchase_to_pool_tra use crate::state_transition::batch_transition::token_unshield_transition::v0::v0_methods::TokenUnshieldTransitionV0Methods; use crate::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; use crate::tokens::token_event::TokenEvent; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use crate::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use crate::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; pub const TOKEN_HISTORY_ID_BYTES: [u8; 32] = [ 45, 67, 89, 21, 34, 216, 145, 78, 156, 243, 17, 58, 202, 190, 13, 92, 61, 40, 122, 201, 84, 99, @@ -126,6 +131,18 @@ pub enum TokenTransition { #[display("TokenDirectPurchaseToPoolTransition({})", "_0")] DirectPurchaseToPool(TokenDirectPurchaseToPoolTransition), + + /// Lists a tradeable token for sale (the token marketplace, protocol version 15). + #[display("TokenPlaceAskTransition({})", "_0")] + PlaceAsk(TokenPlaceAskTransition), + + /// Buys from an open listing (the token marketplace, protocol version 15). + #[display("TokenFillAskTransition({})", "_0")] + FillAsk(TokenFillAskTransition), + + /// Cancels one of the signer's open orders (the token marketplace, protocol version 15). + #[display("TokenCancelOrderTransition({})", "_0")] + CancelOrder(TokenCancelOrderTransition), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -516,6 +533,11 @@ pub trait TokenTransitionV0Methods { /// has to declare which side it is on before it compiles. fn shielded_pool_actions(&self) -> Option<&[SerializedAction]>; + /// Whether this transition places, fills or cancels a token marketplace order, the kinds + /// protocol version 15 introduces. The match names all kinds, so a new kind has to declare + /// which side it is on before it compiles, and the batch's activation gate follows from it. + fn is_marketplace_order(&self) -> bool; + /// Historical document type name for the token history contract fn historical_document_type_name(&self) -> &str; /// Historical document type for the token history contract @@ -563,6 +585,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(t) => t.base(), TokenTransition::ClaimToPool(t) => t.base(), TokenTransition::DirectPurchaseToPool(t) => t.base(), + TokenTransition::PlaceAsk(t) => t.base(), + TokenTransition::FillAsk(t) => t.base(), + TokenTransition::CancelOrder(t) => t.base(), } } @@ -586,6 +611,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(t) => t.base_mut(), TokenTransition::ClaimToPool(t) => t.base_mut(), TokenTransition::DirectPurchaseToPool(t) => t.base_mut(), + TokenTransition::PlaceAsk(t) => t.base_mut(), + TokenTransition::FillAsk(t) => t.base_mut(), + TokenTransition::CancelOrder(t) => t.base_mut(), } } @@ -629,6 +657,10 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::ShieldedTransfer(_) | TokenTransition::ClaimToPool(_) | TokenTransition::DirectPurchaseToPool(_) => None, + // An order is an identity's own: it is never a group action. + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => None, } } @@ -651,7 +683,10 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::Unshield(_) | TokenTransition::ShieldedTransfer(_) | TokenTransition::ClaimToPool(_) - | TokenTransition::DirectPurchaseToPool(_) => false, + | TokenTransition::DirectPurchaseToPool(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => false, } } @@ -674,7 +709,36 @@ impl TokenTransitionV0Methods for TokenTransition { | TokenTransition::EmergencyAction(_) | TokenTransition::ConfigUpdate(_) | TokenTransition::DirectPurchase(_) - | TokenTransition::SetPriceForDirectPurchase(_) => None, + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => None, + } + } + + fn is_marketplace_order(&self) -> bool { + match self { + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => true, + TokenTransition::Burn(_) + | TokenTransition::Mint(_) + | TokenTransition::Transfer(_) + | TokenTransition::Freeze(_) + | TokenTransition::Unfreeze(_) + | TokenTransition::DestroyFrozenFunds(_) + | TokenTransition::Claim(_) + | TokenTransition::EmergencyAction(_) + | TokenTransition::ConfigUpdate(_) + | TokenTransition::DirectPurchase(_) + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::Shield(_) + | TokenTransition::Unshield(_) + | TokenTransition::ShieldedTransfer(_) + | TokenTransition::MintToPool(_) + | TokenTransition::BurnFromPool(_) + | TokenTransition::ClaimToPool(_) + | TokenTransition::DirectPurchaseToPool(_) => false, } } @@ -719,6 +783,9 @@ impl TokenTransitionV0Methods for TokenTransition { TokenTransition::BurnFromPool(_) => "burnFromPool", TokenTransition::ClaimToPool(_) => "claimToPool", TokenTransition::DirectPurchaseToPool(_) => "directPurchaseToPool", + TokenTransition::PlaceAsk(_) => "orderPlaced", + TokenTransition::FillAsk(_) => "orderFilled", + TokenTransition::CancelOrder(_) => "orderCancelled", } } @@ -889,6 +956,28 @@ impl TokenTransitionV0Methods for TokenTransition { set_price_transition.public_note().cloned(), ) } + TokenTransition::PlaceAsk(place_ask) => TokenEvent::OrderPlaced( + TokenOrderSide::Ask.order_id( + place_ask.base().token_id(), + owner_id, + place_ask.base().identity_contract_nonce(), + ), + TokenOrderSide::Ask, + place_ask.amount(), + place_ask.price(), + ), + // we do not know the maker, nor how much the fill takes and costs, before the order + // is read + TokenTransition::FillAsk(fill_ask) => TokenEvent::OrderFilled( + fill_ask.order_id(), + TokenOrderSide::Ask, + Identifier::default(), + fill_ask.max_amount(), + fill_ask.max_total_price(), + ), + TokenTransition::CancelOrder(cancel_order) => { + TokenEvent::OrderCancelled(cancel_order.order_id(), cancel_order.side()) + } }) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs index cb797562b82..c6bb25fc6a0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition_action_type.rs @@ -66,6 +66,15 @@ pub enum TokenTransitionActionType { /// Indicates a transfer inside the token's shielded pool. ShieldedTransfer, + + /// Indicates a token listed for sale on the token marketplace. + PlaceAsk, + + /// Indicates a purchase from an open listing on the token marketplace. + FillAsk, + + /// Indicates an open token marketplace order cancelled by its maker. + CancelOrder, } impl fmt::Display for TokenTransitionActionType { @@ -89,6 +98,9 @@ impl fmt::Display for TokenTransitionActionType { TokenTransitionActionType::BurnFromPool => "BurnFromPool", TokenTransitionActionType::ClaimToPool => "ClaimToPool", TokenTransitionActionType::DirectPurchaseToPool => "DirectPurchaseToPool", + TokenTransitionActionType::PlaceAsk => "PlaceAsk", + TokenTransitionActionType::FillAsk => "FillAsk", + TokenTransitionActionType::CancelOrder => "CancelOrder", }; write!(f, "{}", action_str) } @@ -123,6 +135,9 @@ impl TokenTransitionActionTypeGetter for TokenTransition { TokenTransition::DirectPurchaseToPool(_) => { TokenTransitionActionType::DirectPurchaseToPool } + TokenTransition::PlaceAsk(_) => TokenTransitionActionType::PlaceAsk, + TokenTransition::FillAsk(_) => TokenTransitionActionType::FillAsk, + TokenTransition::CancelOrder(_) => TokenTransitionActionType::CancelOrder, } } } @@ -160,6 +175,9 @@ impl TryFrom<&str> for TokenTransitionActionType { "direct_purchase_to_pool" | "directPurchaseToPool" => { Ok(TokenTransitionActionType::DirectPurchaseToPool) } + "place_ask" | "placeAsk" => Ok(TokenTransitionActionType::PlaceAsk), + "fill_ask" | "fillAsk" => Ok(TokenTransitionActionType::FillAsk), + "cancel_order" | "cancelOrder" => Ok(TokenTransitionActionType::CancelOrder), action_type => Err(ProtocolError::Generic(format!( "unknown token transition action type {action_type}" ))), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 502e3c088ca..34a696e882e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -21,6 +21,7 @@ use crate::identity::signer::Signer; use crate::identity::IdentityPublicKey; use crate::prelude::IdentityNonce; #[cfg(feature = "state-transition-signing")] +#[cfg(feature = "state-transition-signing")] use crate::prelude::UserFeeIncrease; #[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; @@ -40,6 +41,8 @@ use crate::state_transition::StateTransitionSigningOptions; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] use crate::tokens::token_payment_info::TokenPaymentInfo; #[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -1580,6 +1583,178 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransition { }), } } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_place_ask_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + amount, + price, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_place_ask_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_fill_ask_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + order_id, + min_amount, + max_amount, + max_total_price, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_fill_ask_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let resolved_options = options.unwrap_or_default(); + match resolved_options.batch_feature_version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .default_current_version, + ) { + 1 | 0 + if platform_version + .dpp + .state_transition_serialization_versions + .batch_state_transition + .max_version + >= 1 => + { + BatchTransitionV1::new_token_cancel_order_transition( + token_id, + owner_id, + data_contract_id, + token_contract_position, + order_id, + side, + identity_public_key, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + options, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::new_token_cancel_order_transition".to_string(), + known_versions: vec![1], + received: version, + }), + } + } } #[cfg(test)] diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs index dc0108a3b67..fe831846e06 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v1/mod.rs @@ -13,6 +13,7 @@ use crate::identity::signer::Signer; #[cfg(feature = "state-transition-signing")] use crate::identity::IdentityPublicKey; #[cfg(feature = "state-transition-signing")] +#[cfg(feature = "state-transition-signing")] use crate::prelude::{IdentityNonce, UserFeeIncrease}; #[cfg(feature = "state-transition-signing")] use crate::shielded::OrchardBundleParams; @@ -24,6 +25,8 @@ use crate::state_transition::StateTransition; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; #[cfg(feature = "state-transition-signing")] use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; @@ -584,4 +587,60 @@ pub trait DocumentsBatchTransitionMethodsV1: DocumentsBatchTransitionAccessorsV0 platform_version: &PlatformVersion, options: Option, ) -> Result; + + /// Lists `amount` of a tradeable token for sale at `price` credits per whole token. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; + + /// Buys between `min_amount` and `max_amount` from the open listing `order_id`, paying at most `max_total_price`. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; + + /// Cancels the signer's own open order `order_id`. (The token marketplace, protocol version 15.) + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + options: Option, + ) -> Result; } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs index 60958bdaa11..d3439c8f33f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs @@ -19,7 +19,8 @@ pub use self::batched_transition::{ document_replace_transition, document_replace_transition::DocumentReplaceTransition, token_base_transition, token_burn_from_pool_transition, token_burn_from_pool_transition::TokenBurnFromPoolTransition, token_burn_transition, - token_burn_transition::TokenBurnTransition, token_claim_to_pool_transition, + token_burn_transition::TokenBurnTransition, token_cancel_order_transition, + token_cancel_order_transition::TokenCancelOrderTransition, token_claim_to_pool_transition, token_claim_to_pool_transition::TokenClaimToPoolTransition, token_claim_transition, token_claim_transition::TokenClaimTransition, token_config_update_transition, token_config_update_transition::TokenConfigUpdateTransition, @@ -30,10 +31,13 @@ pub use self::batched_transition::{ token_direct_purchase_transition, token_direct_purchase_transition::TokenDirectPurchaseTransition, token_emergency_action_transition, - token_emergency_action_transition::TokenEmergencyActionTransition, token_freeze_transition, + token_emergency_action_transition::TokenEmergencyActionTransition, token_fill_ask_transition, + token_fill_ask_transition::TokenFillAskTransition, token_freeze_transition, token_freeze_transition::TokenFreezeTransition, token_mint_to_pool_transition, token_mint_to_pool_transition::TokenMintToPoolTransition, token_mint_transition, - token_mint_transition::TokenMintTransition, token_set_price_for_direct_purchase_transition, + token_mint_transition::TokenMintTransition, token_place_ask_transition, + token_place_ask_transition::TokenPlaceAskTransition, + token_set_price_for_direct_purchase_transition, token_set_price_for_direct_purchase_transition::TokenSetPriceForDirectPurchaseTransition, token_shield_transition, token_shield_transition::TokenShieldTransition, token_shielded_transfer_transition, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 7ac7a07d18f..4e1cd745d29 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -91,6 +91,17 @@ use crate::state_transition::GetDataContractSecurityLevelRequirementFn; #[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] +#[cfg(feature = "state-transition-signing")] +use crate::tokens::token_order_side::TokenOrderSide; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::{TokenCancelOrderTransition, TokenFillAskTransition, TokenPlaceAskTransition}; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; +#[cfg(feature = "state-transition-signing")] use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; #[cfg(feature = "state-transition-signing")] use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -1482,4 +1493,182 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } Ok(state_transition) } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_place_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + amount: TokenAmount, + price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenPlaceAskTransition::V0(TokenPlaceAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + amount, + price, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_fill_ask_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + min_amount: TokenAmount, + max_amount: TokenAmount, + max_total_price: Credits, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenFillAskTransition::V0(TokenFillAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + order_id, + min_amount, + max_amount, + max_total_price, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } + + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn new_token_cancel_order_transition>( + token_id: Identifier, + owner_id: Identifier, + data_contract_id: Identifier, + token_contract_position: u16, + order_id: Identifier, + side: TokenOrderSide, + identity_public_key: &IdentityPublicKey, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + options: Option, + ) -> Result { + let transition = TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }), + order_id, + side, + }); + + let batch_transition: BatchTransition = BatchTransitionV1 { + owner_id, + transitions: vec![BatchedTransition::Token(transition.into())], + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut state_transition: StateTransition = batch_transition.into(); + if let Some(options) = options { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + options.signing_options, + ) + .await?; + } else { + state_transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + } + Ok(state_transition) + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs index de098140a44..1637e877cbd 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs @@ -46,6 +46,9 @@ use crate::state_transition::batch_transition::token_mint_to_pool_transition::va use crate::state_transition::batch_transition::token_burn_from_pool_transition::validate_structure::TokenBurnFromPoolTransitionStructureValidation; use crate::state_transition::batch_transition::token_claim_to_pool_transition::validate_structure::TokenClaimToPoolTransitionStructureValidation; use crate::state_transition::batch_transition::token_direct_purchase_to_pool_transition::validate_structure::TokenDirectPurchaseToPoolTransitionStructureValidation; +use crate::state_transition::batch_transition::token_place_ask_transition::validate_structure::TokenPlaceAskTransitionStructureValidation; +use crate::state_transition::batch_transition::token_fill_ask_transition::validate_structure::TokenFillAskTransitionStructureValidation; +use crate::state_transition::batch_transition::token_cancel_order_transition::validate_structure::TokenCancelOrderTransitionStructureValidation; use crate::state_transition::batch_transition::token_shielded_transfer_transition::validate_structure::TokenShieldedTransferTransitionStructureValidation; use crate::state_transition::batch_transition::token_unshield_transition::validate_structure::TokenUnshieldTransitionStructureValidation; use crate::state_transition::batch_transition::token_transfer_transition::validate_structure::TokenTransferTransitionStructureValidation; @@ -278,6 +281,18 @@ impl BatchTransition { TokenTransition::DirectPurchaseToPool(direct_purchase_to_pool_transition) => { direct_purchase_to_pool_transition.validate_structure(platform_version)? } + // The token marketplace arms, like the pool arms above, are reached only from + // the version that admits them: the batch's `is_allowed` gate refuses every order + // kind before protocol version 15. + TokenTransition::PlaceAsk(place_ask_transition) => { + place_ask_transition.validate_structure(platform_version)? + } + TokenTransition::FillAsk(fill_ask_transition) => { + fill_ask_transition.validate_structure(platform_version)? + } + TokenTransition::CancelOrder(cancel_order_transition) => { + cancel_order_transition.validate_structure(platform_version)? + } }; if !consensus_result.is_valid() { diff --git a/packages/rs-dpp/src/tokens/mod.rs b/packages/rs-dpp/src/tokens/mod.rs index b827b2c0dcf..9d1b2b57a73 100644 --- a/packages/rs-dpp/src/tokens/mod.rs +++ b/packages/rs-dpp/src/tokens/mod.rs @@ -13,6 +13,8 @@ pub mod info; pub mod status; pub mod token_amount_on_contract_token; pub mod token_event; +pub mod token_order; +pub mod token_order_side; pub mod token_payment_info; pub mod token_pricing_schedule; diff --git a/packages/rs-dpp/src/tokens/token_event.rs b/packages/rs-dpp/src/tokens/token_event.rs index 32e5bf396ec..e54d7c14bcb 100644 --- a/packages/rs-dpp/src/tokens/token_event.rs +++ b/packages/rs-dpp/src/tokens/token_event.rs @@ -34,6 +34,7 @@ pub type TokenEventPersonalEncryptedNote = Option<( )>; use crate::serialization::PlatformSerializableWithPlatformVersion; use crate::tokens::emergency_action::TokenEmergencyAction; +use crate::tokens::token_order_side::TokenOrderSide; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; use crate::tokens::SharedEncryptedNote; use crate::ProtocolError; @@ -208,6 +209,29 @@ pub enum TokenEvent { /// - `TokenAmount`: The amount of tokens purchased. /// - `Credits`: The number of credits paid. DirectPurchaseToPool(TokenAmount, Credits), + + /// Event representing a token marketplace order placed (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: Whether the order is a listing or an offer. + /// - `TokenAmount`: The amount listed or wanted, in base units. + /// - `Credits`: The price, in credits per whole token. + OrderPlaced(Identifier, TokenOrderSide, TokenAmount, Credits), + + /// Event representing a fill of a token marketplace order (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: The side of the order filled. + /// - `Identifier`: The order's maker. + /// - `TokenAmount`: The amount the fill took, in base units. + /// - `Credits`: The credits the fill moved between buyer and seller. + OrderFilled(Identifier, TokenOrderSide, Identifier, TokenAmount, Credits), + + /// Event representing a cancel of a token marketplace order (protocol version 15). + /// + /// - `Identifier`: The order id. + /// - `TokenOrderSide`: The side of the order cancelled. + OrderCancelled(Identifier, TokenOrderSide), } // Manual impl because TokenEvent is a flat enum with u64-alias tuple variants @@ -372,6 +396,32 @@ impl serde::Serialize for TokenEvent { m.serialize_entry("credits", &SafeU64(credits))?; m.end() } + TokenEvent::OrderPlaced(order_id, side, amount, price) => { + let mut m = serializer.serialize_map(Some(5))?; + m.serialize_entry("$type", "orderPlaced")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.serialize_entry("amount", &SafeU64(amount))?; + m.serialize_entry("price", &SafeU64(price))?; + m.end() + } + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => { + let mut m = serializer.serialize_map(Some(6))?; + m.serialize_entry("$type", "orderFilled")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.serialize_entry("maker", maker)?; + m.serialize_entry("amount", &SafeU64(amount))?; + m.serialize_entry("credits", &SafeU64(credits))?; + m.end() + } + TokenEvent::OrderCancelled(order_id, side) => { + let mut m = serializer.serialize_map(Some(3))?; + m.serialize_entry("$type", "orderCancelled")?; + m.serialize_entry("orderId", order_id)?; + m.serialize_entry("side", side)?; + m.end() + } } } } @@ -423,6 +473,10 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { let mut action: Option = None; let mut configuration_change: Option = None; let mut pricing_schedule: Option = None; + let mut order_id: Option = None; + let mut side: Option = None; + let mut maker: Option = None; + let mut price: Option = None; while let Some(key) = map.next_key::()? { match key.as_str() { @@ -444,6 +498,10 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { "action" => action = Some(map.next_value()?), "configurationChange" => configuration_change = Some(map.next_value()?), "pricingSchedule" => pricing_schedule = map.next_value()?, + "orderId" => order_id = Some(map.next_value()?), + "side" => side = Some(map.next_value()?), + "maker" => maker = Some(map.next_value()?), + "price" => price = Some(map.next_value::()?.0), _ => { let _: IgnoredAny = map.next_value()?; } @@ -530,6 +588,23 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { amount.ok_or_else(|| A::Error::missing_field("amount"))?, credits.ok_or_else(|| A::Error::missing_field("credits"))?, )), + "orderPlaced" => Ok(TokenEvent::OrderPlaced( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + amount.ok_or_else(|| A::Error::missing_field("amount"))?, + price.ok_or_else(|| A::Error::missing_field("price"))?, + )), + "orderFilled" => Ok(TokenEvent::OrderFilled( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + maker.ok_or_else(|| A::Error::missing_field("maker"))?, + amount.ok_or_else(|| A::Error::missing_field("amount"))?, + credits.ok_or_else(|| A::Error::missing_field("credits"))?, + )), + "orderCancelled" => Ok(TokenEvent::OrderCancelled( + order_id.ok_or_else(|| A::Error::missing_field("orderId"))?, + side.ok_or_else(|| A::Error::missing_field("side"))?, + )), other => Err(A::Error::unknown_variant( other, &[ @@ -551,6 +626,9 @@ impl<'de> serde::Deserialize<'de> for TokenEvent { "burnFromPool", "claimToPool", "directPurchaseToPool", + "orderPlaced", + "orderFilled", + "orderCancelled", ], )), } @@ -779,6 +857,19 @@ impl fmt::Display for TokenEvent { "Direct purchase of {} into the token pool for {} credits", amount, credits ), + TokenEvent::OrderPlaced(order_id, side, amount, price) => write!( + f, + "Order {} placed: {} of {} at {} credits per whole token", + order_id, side, amount, price + ), + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => write!( + f, + "Order {} ({}) of {} filled: {} for {} credits", + order_id, side, maker, amount, credits + ), + TokenEvent::OrderCancelled(order_id, side) => { + write!(f, "Order {} ({}) cancelled", order_id, side) + } } } } @@ -811,6 +902,9 @@ impl TokenEvent { TokenEvent::BurnFromPool(..) => "burnFromPool", TokenEvent::ClaimToPool(..) => "claimToPool", TokenEvent::DirectPurchaseToPool(..) => "directPurchaseToPool", + TokenEvent::OrderPlaced(..) => "orderPlaced", + TokenEvent::OrderFilled(..) => "orderFilled", + TokenEvent::OrderCancelled(..) => "orderCancelled", } } @@ -839,7 +933,10 @@ impl TokenEvent { | TokenEvent::Unshield(_, _) | TokenEvent::ShieldedTransfer | TokenEvent::ClaimToPool(_) - | TokenEvent::DirectPurchaseToPool(_, _) => None, + | TokenEvent::DirectPurchaseToPool(_, _) + | TokenEvent::OrderPlaced(..) + | TokenEvent::OrderFilled(..) + | TokenEvent::OrderCancelled(..) => None, } } @@ -1068,6 +1165,26 @@ impl TokenEvent { ("tokenAmount".to_string(), amount.into()), ("purchaseCost".to_string(), total_cost.into()), ]), + TokenEvent::OrderPlaced(order_id, side, amount, price) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ("amount".to_string(), amount.into()), + ("price".to_string(), price.into()), + ]), + TokenEvent::OrderFilled(order_id, side, maker, amount, credits) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ("makerId".to_string(), maker.into()), + ("amount".to_string(), amount.into()), + ("credits".to_string(), credits.into()), + ]), + TokenEvent::OrderCancelled(order_id, side) => BTreeMap::from([ + ("tokenId".to_string(), token_id.into()), + ("orderId".to_string(), order_id.into()), + ("side".to_string(), (side as u8).into()), + ]), }; let document: Document = DocumentV0 { diff --git a/packages/rs-dpp/src/tokens/token_order.rs b/packages/rs-dpp/src/tokens/token_order.rs new file mode 100644 index 00000000000..65a965c9337 --- /dev/null +++ b/packages/rs-dpp/src/tokens/token_order.rs @@ -0,0 +1,414 @@ +use crate::balances::credits::TokenAmount; +use crate::block::block_info::BlockInfo; +use crate::document::{Document, DocumentV0, DocumentV0Getters}; +use crate::fee::Credits; +use crate::prelude::Revision; +use crate::tokens::token_order_side::TokenOrderSide; +use crate::ProtocolError; +use platform_value::{Identifier, Value}; +use std::collections::BTreeMap; + +/// The token market contract's property names of an order. +pub mod property_names { + pub const TOKEN_ID: &str = "tokenId"; + pub const PRICE: &str = "price"; + pub const AMOUNT: &str = "amount"; + pub const REMAINING: &str = "remaining"; + pub const ESCROW: &str = "escrow"; + pub const DECIMALS: &str = "decimals"; +} + +/// An open order of the token marketplace (protocol version 15), as the token market contract +/// stores it: a listing (`Ask`) of tokens for sale, or an offer (`Bid`) to buy them. +/// +/// The price is in credits per whole token: 10 to the power of `decimals` base units, the token's +/// decimals when the order was placed. Every fill prices the order at that scale, so a later +/// change to the token's decimals changes neither what a listing costs nor what an offer pays. +/// A fill pays the change in the order's rounded running total, so splitting an order into +/// several fills never changes what it costs in all: a listing's buyers pay +/// `ceil((filled + q) * price / unit) - ceil(filled * price / unit)` for `q` base units, and an +/// offer's sellers receive the same difference rounded down. The rounding favours the order's +/// maker, and is never more than one credit per fill. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenOrder { + /// The order id: its document id in the token market contract and the key of its escrow. + pub id: Identifier, + /// A listing or an offer. + pub side: TokenOrderSide, + /// The identity that placed the order. + pub maker_id: Identifier, + /// The token the order is for. + pub token_id: Identifier, + /// The price, in credits per whole token. + pub price: Credits, + /// The amount, in base units, listed or wanted when the order was placed. + pub amount: TokenAmount, + /// The amount still open. + pub remaining: TokenAmount, + /// For an offer, the credits still held in escrow; 0 for a listing, whose escrow holds + /// tokens (`remaining`). + pub escrow: Credits, + /// The token's decimals when the order was placed, the scale of its price: what the token + /// market contract's `generatedFrom` (`sys.tokens.decimals` of `tokenId`) generates, and + /// keeps for the life of the order. + pub decimals: u8, +} + +/// 10 to the power of `decimals`, the base units in one whole token, or `None` above +/// `u128::MAX` (39 decimals or more). A token's decimals are a `u8`, so the unit may not fit. +fn unit(decimals: u8) -> Option { + 10u128.checked_pow(decimals as u32) +} + +/// What `filled` base units at `price` credits per whole token cost, rounded up or down, with +/// `unit` base units in a whole token (`None` when that is more than `u128` holds). +fn total_at(filled: TokenAmount, price: Credits, unit: Option, round_up: bool) -> u128 { + // At most `i64::MAX * i64::MAX`, under `u128::MAX`. + let value = filled as u128 * price as u128; + match unit { + Some(unit) if round_up => value.div_ceil(unit), + Some(unit) => value / unit, + // A whole token is more base units than `u128` holds, so more than `value`: a nonzero + // total is under one credit, 1 rounded up and 0 rounded down. + None if round_up => u128::from(value > 0), + None => 0, + } +} + +impl TokenOrder { + /// The amount filled so far. + pub fn filled(&self) -> TokenAmount { + self.amount.saturating_sub(self.remaining) + } + + /// The amount a fill asking for between `min_amount` and `max_amount` takes, or `None` when + /// no amount in that range keeps the token's rules. `min_fill` is the token's minimum fill + /// (`TokenTradeMode::Tradeable`) when the fill executes. + /// + /// The fill takes as much as it may: everything open, up to `max_amount`. When that would + /// leave less than `min_fill` open, it takes less, leaving exactly `min_fill`, if that still + /// takes at least `min_fill` and `min_amount`. A fill that does not take everything open takes + /// at least `min_fill`; one that takes everything open may take less, so an order left under + /// a minimum the token raised since is still filled whole. + pub fn fill_amount( + &self, + min_amount: TokenAmount, + max_amount: TokenAmount, + min_fill: TokenAmount, + ) -> Option { + let mut take = max_amount.min(self.remaining); + if take < self.remaining { + let left = self.remaining - take; + if left < min_fill { + take = self.remaining.checked_sub(min_fill)?; + } + if take < min_fill { + return None; + } + } + if take == 0 || take < min_amount { + return None; + } + Some(take) + } + + /// The credits a fill of `fill_amount` moves: what a listing's buyer pays, rounded up, or + /// what an offer's seller receives, rounded down, as the change in the order's running total + /// at the order's own `decimals`. + pub fn fill_credits(&self, fill_amount: TokenAmount) -> Result { + let unit = unit(self.decimals); + let filled = self.filled(); + let filled_after = filled + .checked_add(fill_amount) + .ok_or(ProtocolError::Overflow( + "a fill takes more than an order holds", + ))?; + let round_up = matches!(self.side, TokenOrderSide::Ask); + let before = total_at(filled, self.price, unit, round_up); + let after = total_at(filled_after, self.price, unit, round_up); + Credits::try_from(after - before) + .map_err(|_| ProtocolError::Overflow("a fill moves more credits than fit in u64")) + } + + /// What an order of `amount` base units at `price` credits per whole token is worth, rounded + /// up, for a token with `decimals` decimals: everything its buyers pay for a listing, and the + /// escrow an offer holds. `None` when it is more credits than an order holds (`i64::MAX`). + pub fn order_value(amount: TokenAmount, price: Credits, decimals: u8) -> Option { + let value = total_at(amount, price, unit(decimals), true); + if value > i64::MAX as u128 { + None + } else { + Some(value as Credits) + } + } + + /// Reads an order of `side` from its token market contract document. + pub fn from_document(document: &Document, side: TokenOrderSide) -> Result { + let properties = document.properties(); + let integer = |name: &str| -> Result { + properties + .get(name) + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution(format!( + "a token order document has no {name}" + )) + })? + .to_integer::() + .map_err(ProtocolError::ValueError) + }; + let token_id = properties + .get(property_names::TOKEN_ID) + .ok_or_else(|| { + ProtocolError::CorruptedCodeExecution( + "a token order document has no tokenId".to_string(), + ) + })? + .to_identifier() + .map_err(ProtocolError::ValueError)?; + let escrow = match side { + TokenOrderSide::Ask => 0, + TokenOrderSide::Bid => integer(property_names::ESCROW)?, + }; + let decimals = u8::try_from(integer(property_names::DECIMALS)?).map_err(|_| { + ProtocolError::CorruptedCodeExecution( + "a token order document's decimals are above 255".to_string(), + ) + })?; + Ok(TokenOrder { + id: document.id(), + side, + maker_id: document.owner_id(), + token_id, + price: integer(property_names::PRICE)?, + amount: integer(property_names::AMOUNT)?, + remaining: integer(property_names::REMAINING)?, + escrow, + decimals, + }) + } + + /// The order's properties as the token market contract stores them: every integer a signed + /// 8-byte value (the contract stores integers as i64). + fn properties(&self) -> Result, ProtocolError> { + let signed = |value: u64| -> Result { + i64::try_from(value) + .map(Value::I64) + .map_err(|_| ProtocolError::Overflow("a token order integer is above i64::MAX")) + }; + let mut properties = BTreeMap::from([ + ( + property_names::TOKEN_ID.to_string(), + Value::Identifier(self.token_id.to_buffer()), + ), + (property_names::PRICE.to_string(), signed(self.price)?), + (property_names::AMOUNT.to_string(), signed(self.amount)?), + ( + property_names::REMAINING.to_string(), + signed(self.remaining)?, + ), + ( + property_names::DECIMALS.to_string(), + Value::I64(self.decimals as i64), + ), + ]); + if self.side == TokenOrderSide::Bid { + properties.insert(property_names::ESCROW.to_string(), signed(self.escrow)?); + } + Ok(properties) + } + + /// The document of a new order, placed at `block_info`. + pub fn new_document(&self, block_info: &BlockInfo) -> Result { + Ok(DocumentV0 { + contract_version: None, + id: self.id, + owner_id: self.maker_id, + properties: self.properties()?, + revision: Some(1), + created_at: Some(block_info.time_ms), + updated_at: Some(block_info.time_ms), + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + moderated_at: None, + moderated_by: None, + } + .into()) + } + + /// The document `stored` after a partial fill left this order's `remaining` (and, for an + /// offer, its `escrow`): the same document at the next revision, updated at `block_info`. + pub fn updated_document( + &self, + stored: &Document, + block_info: &BlockInfo, + ) -> Result { + let revision: Revision = stored.revision().unwrap_or(1).saturating_add(1); + let mut document = stored.clone(); + let Document::V0(ref mut v0) = document; + v0.properties = self.properties()?; + v0.revision = Some(revision); + v0.updated_at = Some(block_info.time_ms); + Ok(document) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn order(side: TokenOrderSide, amount: TokenAmount, remaining: TokenAmount) -> TokenOrder { + TokenOrder { + id: Identifier::from([1; 32]), + side, + maker_id: Identifier::from([2; 32]), + token_id: Identifier::from([3; 32]), + price: 333, + amount, + remaining, + escrow: 0, + decimals: 2, + } + } + + #[test] + fn should_take_everything_open_up_to_the_fill_maximum() { + let listing = order(TokenOrderSide::Ask, 1000, 1000); + assert_eq!(listing.fill_amount(1, 400, 1), Some(400)); + assert_eq!(listing.fill_amount(1, 5000, 1), Some(1000)); + assert_eq!(listing.fill_amount(1001, 5000, 1), None); + } + + #[test] + fn should_leave_at_least_the_minimum_fill_open() { + let listing = order(TokenOrderSide::Ask, 1000, 1000); + // 950 would leave 50: the fill takes 900 and leaves 100. + assert_eq!(listing.fill_amount(1, 950, 100), Some(900)); + // Unless the fill asked for more than that. + assert_eq!(listing.fill_amount(901, 950, 100), None); + // A fill under the minimum fill that does not take everything is refused. + assert_eq!(listing.fill_amount(1, 50, 100), None); + // Everything open is always takeable, even under a minimum the token raised since. + let tail = order(TokenOrderSide::Ask, 1000, 60); + assert_eq!(tail.fill_amount(1, 1000, 100), Some(60)); + } + + #[test] + fn should_sell_a_listing_under_the_minimum_fill_only_whole() { + let listing = order(TokenOrderSide::Ask, 1000, 1000); + assert_eq!(listing.fill_amount(1, 999, 1000), None); + assert_eq!(listing.fill_amount(1, 1000, 1000), Some(1000)); + } + + /// 2 decimals at 333 credits per token is 3.33 credits per base unit. + #[test] + fn should_charge_the_running_total_so_splitting_never_changes_the_cost() { + let mut listing = order(TokenOrderSide::Ask, 3, 3); + let mut paid = vec![]; + for _ in 0..3 { + let credits = listing.fill_credits(1).expect("expected the cost"); + paid.push(credits); + listing.remaining -= 1; + } + assert_eq!(paid, vec![4, 3, 3]); + let whole = order(TokenOrderSide::Ask, 3, 3); + assert_eq!(whole.fill_credits(3).expect("expected the cost"), 10); + } + + #[test] + fn should_round_an_offers_payouts_down_and_keep_them_within_its_escrow() { + let escrow = TokenOrder::order_value(1001, 333, 2).expect("expected a value"); + assert_eq!(escrow, 3334); + let mut offer = order(TokenOrderSide::Bid, 1001, 1001); + let mut paid = 0; + while offer.remaining > 0 { + let take = offer.remaining.min(7); + paid += offer.fill_credits(take).expect("expected the payout"); + offer.remaining -= take; + } + assert_eq!(paid, 3333); + assert!(paid <= escrow); + } + + /// An order prices its fills at the decimals it records, whatever the token's are now: the + /// same price and amount cost 100 times more at 0 decimals than at 2. + #[test] + fn should_price_fills_at_the_orders_own_decimals() { + let mut listing = order(TokenOrderSide::Ask, 100, 100); + listing.price = 100; + assert_eq!(listing.fill_credits(10).expect("expected the cost"), 10); + listing.decimals = 0; + assert_eq!(listing.fill_credits(10).expect("expected the cost"), 1_000); + } + + #[test] + fn should_refuse_an_order_worth_more_than_an_order_holds() { + assert_eq!(TokenOrder::order_value(i64::MAX as u64, 2, 0), None); + assert_eq!( + TokenOrder::order_value(i64::MAX as u64, 1, 0), + Some(i64::MAX as u64) + ); + } + + /// A token's decimals are a \`u8\`. From 39 decimals a whole token is more base units than + /// \`u128\` holds, and more than any amount times any price, so an order is worth one credit + /// rounded up (a listing's first fill pays it) and nothing rounded down (an offer's sales + /// are paid nothing). + #[test] + fn should_price_orders_of_tokens_with_39_or_more_decimals() { + let max = i64::MAX as u64; + for decimals in [39, 255] { + assert_eq!(TokenOrder::order_value(1, 1, decimals), Some(1)); + assert_eq!(TokenOrder::order_value(max, max, decimals), Some(1)); + + let mut listing = order(TokenOrderSide::Ask, 1_000, 1_000); + listing.decimals = decimals; + listing.price = max; + assert_eq!(listing.fill_credits(400).ok(), Some(1)); + listing.remaining = 600; + assert_eq!(listing.fill_credits(600).ok(), Some(0)); + + let mut offer = order(TokenOrderSide::Bid, 1_000, 1_000); + offer.decimals = decimals; + offer.price = max; + assert_eq!(offer.fill_credits(1_000).ok(), Some(0)); + } + // 38 decimals still fit: 10^38 base units in a whole token. + assert_eq!( + TokenOrder::order_value(max, max, 38), + Some((max as u128 * max as u128).div_ceil(10u128.pow(38)) as u64) + ); + } + + #[test] + fn should_round_trip_through_its_document() { + let mut offer = order(TokenOrderSide::Bid, 1000, 400); + offer.escrow = 1998; + let block_info = BlockInfo::default(); + let document = offer + .new_document(&block_info) + .expect("expected a document"); + assert_eq!( + TokenOrder::from_document(&document, TokenOrderSide::Bid).expect("expected an order"), + offer + ); + assert_eq!(document.revision(), Some(1)); + + offer.remaining = 100; + let updated = offer + .updated_document(&document, &block_info) + .expect("expected a document"); + assert_eq!(updated.revision(), Some(2)); + assert_eq!( + TokenOrder::from_document(&updated, TokenOrderSide::Bid) + .expect("expected an order") + .remaining, + 100 + ); + } +} diff --git a/packages/rs-dpp/src/tokens/token_order_side.rs b/packages/rs-dpp/src/tokens/token_order_side.rs new file mode 100644 index 00000000000..bcc1bb962fb --- /dev/null +++ b/packages/rs-dpp/src/tokens/token_order_side.rs @@ -0,0 +1,93 @@ +use crate::document::Document; +use crate::prelude::IdentityNonce; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The side of a token marketplace order (protocol version 15): a listing (`Ask`), which holds +/// the tokens it offers for sale in escrow, or an offer (`Bid`), which holds the credits it offers +/// in escrow. Each side is its own document type of the token market contract. +#[derive( + Debug, + Clone, + Copy, + Default, + Encode, + Decode, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub enum TokenOrderSide { + /// A listing: tokens for sale at a price. + #[default] + Ask = 0, + /// An offer: credits to buy tokens at a price. + Bid = 1, +} + +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +impl JsonConvertible for TokenOrderSide {} + +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +impl ValueConvertible for TokenOrderSide {} + +impl TokenOrderSide { + /// The token market contract document type that holds the orders of this side. + pub fn document_type_name(&self) -> &'static str { + match self { + TokenOrderSide::Ask => "ask", + TokenOrderSide::Bid => "bid", + } + } +} + +impl fmt::Display for TokenOrderSide { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TokenOrderSide::Ask => write!(f, "ask"), + TokenOrderSide::Bid => write!(f, "bid"), + } + } +} + +impl TokenOrderSide { + /// The id of the order of this side that `maker` places for `token_id` with the identity + /// contract nonce `nonce`: known before the transition is broadcast, and unique, since the + /// nonce is used once. It is the id of the order's document in the token market contract and + /// the key of its escrow entry. The token id takes the contract id's place, as it does in + /// the ids of token history documents. + pub fn order_id( + &self, + token_id: Identifier, + maker: Identifier, + nonce: IdentityNonce, + ) -> Identifier { + Document::generate_document_id_v0( + &token_id, + &maker, + self.document_type_name(), + nonce.to_be_bytes().as_slice(), + ) + } + + /// The number of bytes `order_id` hashes (twice, with SHA-256): the token id and the maker's + /// id (32 bytes each), the document type name and the nonce. + pub fn order_id_preimage_size(&self) -> usize { + 2 * 32 + self.document_type_name().len() + std::mem::size_of::() + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 9fc1c20a620..52c5b047e30 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -171,12 +171,19 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { } } if platform_version.protocol_version < TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION { - // The token marketplace: a configuration update that makes a token - // tradeable. Software older than the marketplace cannot decode one, so it - // is refused unpaid, as that software refuses it. + // The token marketplace: the order kinds, and a configuration update that + // makes a token tradeable. Software older than the marketplace cannot decode + // either, so both are refused unpaid, as that software refuses them. if let Some(transition) = st.transitions_iter() .find_map(|transition| match transition { + // Asked as one question, like the pool kinds above, so that an + // order kind added later is gated without being listed here. + BatchedTransitionRef::Token(token_transition) + if token_transition.is_marketplace_order() => + { + Some(format!("Token{}", token_transition.action_type())) + } BatchedTransitionRef::Token(TokenTransition::ConfigUpdate( config_update, )) if matches!( @@ -805,7 +812,10 @@ mod tests { use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use dpp::platform_value::BinaryData; use dpp::prelude::IdentityNonce; + use dpp::state_transition::batch_transition::batched_transition::token_cancel_order_transition::{TokenCancelOrderTransition, TokenCancelOrderTransitionV0}; use dpp::state_transition::batch_transition::batched_transition::token_config_update_transition::{TokenConfigUpdateTransition, TokenConfigUpdateTransitionV0}; + use dpp::state_transition::batch_transition::batched_transition::token_fill_ask_transition::{TokenFillAskTransition, TokenFillAskTransitionV0}; + use dpp::state_transition::batch_transition::batched_transition::token_place_ask_transition::{TokenPlaceAskTransition, TokenPlaceAskTransitionV0}; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; use dpp::state_transition::batch_transition::BatchTransitionV1; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; @@ -864,7 +874,7 @@ mod tests { #[test] fn should_refuse_making_a_token_tradeable_before_protocol_version_15() { - let result = trade_mode_update(TokenTradeMode::Tradeable) + let result = trade_mode_update(TokenTradeMode::Tradeable { min_fill: 1 }) .validate_is_allowed(protocol_version_14()) .expect("should not error"); assert_matches!( @@ -891,7 +901,7 @@ mod tests { fn should_allow_making_a_token_tradeable_from_protocol_version_15() { let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) .expect("the version that introduced the token marketplace"); - let result = trade_mode_update(TokenTradeMode::Tradeable) + let result = trade_mode_update(TokenTradeMode::Tradeable { min_fill: 1 }) .validate_is_allowed(platform_version) .expect("should not error"); assert!(result.is_valid(), "{:?}", result.errors); @@ -903,11 +913,12 @@ mod tests { #[test] fn should_run_the_check_on_a_contract_with_a_tradeable_token() { let platform_version = PlatformVersion::latest(); - assert!( - contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) - .has_is_allowed_validation() - .expect("should not error") - ); + assert!(contract_create_with_trade_mode( + TokenTradeMode::Tradeable { min_fill: 1 }, + platform_version + ) + .has_is_allowed_validation() + .expect("should not error")); assert!(!contract_create_with_trade_mode( TokenTradeMode::NotTradeable, platform_version @@ -919,10 +930,12 @@ mod tests { #[test] fn should_refuse_a_contract_with_a_tradeable_token_before_protocol_version_15() { let platform_version = protocol_version_14(); - let result = - contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) - .validate_is_allowed(platform_version) - .expect("should not error"); + let result = contract_create_with_trade_mode( + TokenTradeMode::Tradeable { min_fill: 1 }, + platform_version, + ) + .validate_is_allowed(platform_version) + .expect("should not error"); assert_matches!( result.errors.as_slice(), [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] @@ -936,11 +949,80 @@ mod tests { fn should_allow_a_contract_with_a_tradeable_token_from_protocol_version_15() { let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) .expect("the version that introduced the token marketplace"); - let result = - contract_create_with_trade_mode(TokenTradeMode::Tradeable, platform_version) + let result = contract_create_with_trade_mode( + TokenTradeMode::Tradeable { min_fill: 1 }, + platform_version, + ) + .validate_is_allowed(platform_version) + .expect("should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + + /// A batch whose only transition is `token_transition`. + fn batch_with(token_transition: TokenTransition) -> StateTransition { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Default::default(), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::default(), + })) + } + + /// Every order kind, with the name the gate reports it under. + fn every_order_kind() -> Vec<(TokenTransition, &'static str)> { + vec![ + ( + TokenTransition::PlaceAsk(TokenPlaceAskTransition::V0( + TokenPlaceAskTransitionV0::default(), + )), + "TokenPlaceAsk", + ), + ( + TokenTransition::FillAsk(TokenFillAskTransition::V0( + TokenFillAskTransitionV0::default(), + )), + "TokenFillAsk", + ), + ( + TokenTransition::CancelOrder(TokenCancelOrderTransition::V0( + TokenCancelOrderTransitionV0::default(), + )), + "TokenCancelOrder", + ), + ] + } + + /// Software older than the marketplace cannot decode an order kind, so each is refused + /// unpaid before protocol version 15, under the name a client is told. + #[test] + fn should_refuse_every_order_kind_before_protocol_version_15() { + for (token_transition, expected_name) in every_order_kind() { + let result = batch_with(token_transition) + .validate_is_allowed(protocol_version_14()) + .expect("should not error"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.state_transition_type() == expected_name + && error.required_protocol_version() + == TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION, + "expected {expected_name} to be refused, got {:?}", + result.errors + ); + } + } + + #[test] + fn should_allow_every_order_kind_from_protocol_version_15() { + let platform_version = PlatformVersion::get(TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) + .expect("the version that introduced the token marketplace"); + for (token_transition, name) in every_order_kind() { + let result = batch_with(token_transition) .validate_is_allowed(platform_version) .expect("should not error"); - assert!(result.is_valid(), "{:?}", result.errors); + assert!(result.is_valid(), "{name}: {:?}", result.errors); + } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 04f4defceca..f0b5e6bdcbe 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -1199,7 +1199,10 @@ fn validate_batch_token_shielded_proofs( | TokenTransition::EmergencyAction(_) | TokenTransition::ConfigUpdate(_) | TokenTransition::DirectPurchase(_) - | TokenTransition::SetPriceForDirectPurchase(_), + | TokenTransition::SetPriceForDirectPurchase(_) + | TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_), ) => continue, }; if let Err(e) = result { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs index 2870bd81b70..c85918925bf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod token_base_transition_action; pub(crate) mod token_burn_from_pool_transition_action; pub(crate) mod token_burn_transition_action; +pub(crate) mod token_cancel_order_transition_action; pub(crate) mod token_claim_to_pool_transition_action; pub(crate) mod token_claim_transition_action; pub(crate) mod token_config_update_transition_action; @@ -8,9 +9,12 @@ pub(crate) mod token_destroy_frozen_funds_transition_action; pub(crate) mod token_direct_purchase_to_pool_transition_action; pub(crate) mod token_direct_purchase_transition_action; pub(crate) mod token_emergency_action_transition_action; +pub(crate) mod token_fill_ask_transition_action; pub(crate) mod token_freeze_transition_action; pub(crate) mod token_mint_to_pool_transition_action; pub(crate) mod token_mint_transition_action; +pub(crate) mod token_order_common; +pub(crate) mod token_place_ask_transition_action; pub(crate) mod token_set_price_for_direct_purchase_transition_action; pub(crate) mod token_shield_transition_action; pub(crate) mod token_shielded_pool_common; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs index 4b543cd8014..3d076c01344 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_base_transition_action/structure_v0/mod.rs @@ -1,8 +1,3 @@ -use dpp::consensus::basic::BasicError; -use dpp::consensus::basic::token::ContractHasNoTokensError; -use dpp::consensus::basic::token::InvalidTokenPositionError; -use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::validation::{SimpleConsensusValidationResult}; use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; use dpp::version::PlatformVersion; @@ -19,26 +14,11 @@ impl TokenBaseTransitionActionStructureValidationV0 for TokenBaseTransitionActio &self, _platform_version: &PlatformVersion, ) -> Result { - let token_position = self.token_position(); - let contract = self.data_contract_fetch_info_ref(); - if contract.contract.tokens().get(&token_position).is_none() { - return if contract.contract.tokens().is_empty() { - Ok(SimpleConsensusValidationResult::new_with_error( - BasicError::ContractHasNoTokensError(ContractHasNoTokensError::new( - contract.contract.id(), - )) - .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::new_with_error( - BasicError::InvalidTokenPositionError(InvalidTokenPositionError::new( - contract.contract.tokens().keys().last().copied(), - token_position, - )) - .into(), - )) - }; - } - Ok(SimpleConsensusValidationResult::default()) + // The rule lives with the action, where a transformer that needs the token's + // configuration earlier reads it too. + Ok(match self.token_configuration_or_position_error() { + Ok(_) => SimpleConsensusValidationResult::default(), + Err(error) => SimpleConsensusValidationResult::new_with_error(error), + }) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs new file mode 100644 index 00000000000..9c4bc6f2bdc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/mod.rs @@ -0,0 +1,59 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +use crate::error::execution::ExecutionError; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_cancel_order_transition_action::state_v0::TokenCancelOrderTransitionActionStateValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; + +pub trait TokenCancelOrderTransitionActionValidation { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionActionValidation for TokenCancelOrderTransitionAction { + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_cancel_order_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenCancelOrderTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..36d0682bd62 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_cancel_order_transition_action/state_v0/mod.rs @@ -0,0 +1,46 @@ +use dpp::block::block_info::BlockInfo; +use dpp::prelude::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenCancelOrderTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenCancelOrderTransitionActionStateValidationV0 for TokenCancelOrderTransitionAction { + /// The transformer already refused an order that is not open or not the sender's. A cancel + /// needs nothing more: it returns the escrow to its maker even when the token is paused, no + /// longer tradeable or the maker's account is frozen, so an order can always be withdrawn. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs new file mode 100644 index 00000000000..eee4fad3b3c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/mod.rs @@ -0,0 +1,88 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +use crate::error::execution::ExecutionError; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::state_v0::TokenFillAskTransitionActionStateValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::structure_v0::TokenFillAskTransitionActionStructureValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; +mod structure_v0; + +pub trait TokenFillAskTransitionActionValidation { + /// Checks that need only the contract the action carries and the block, run with the + /// batch's advanced structure so that check_tx refuses a failing transition too. + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result; + + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionValidation for TokenFillAskTransitionAction { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::validate_structure".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_fill_ask_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..45103938d52 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/state_v0/mod.rs @@ -0,0 +1,103 @@ +use dpp::block::block_info::BlockInfo; +use dpp::prelude::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_account_not_frozen; +use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenFillAskTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionStateValidationV0 for TokenFillAskTransitionAction { + /// The transformer already read the listing and refused a fill it cannot meet, and the + /// batch's advanced structure a token that is no longer tradeable. A fill also needs a token + /// that is not paused, a seller whose account is not frozen, and a buyer whose account is not + /// frozen unless the token allows transfers to frozen balances. The buyer's credit balance is checked with the batch's fees, which count + /// the fill's cost as a purchase. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_not_paused( + platform, + self.token_id(), + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + self.order().maker_id, + "fillAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + if !self + .base() + .token_configuration()? + .is_allowed_transfer_to_frozen_balance() + { + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + owner_id, + "fillAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs new file mode 100644 index 00000000000..17696249f74 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_fill_ask_transition_action/structure_v0/mod.rs @@ -0,0 +1,25 @@ +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; + +use crate::error::Error; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_tradeable; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait TokenFillAskTransitionActionStructureValidationV0 +{ + fn validate_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenFillAskTransitionActionStructureValidationV0 for TokenFillAskTransitionAction { + /// A fill needs a token that is still tradeable: one whose trade mode changed back to + /// `NotTradeable` keeps its open listings, which can be cancelled but no longer filled. + fn validate_structure_v0( + &self, + _platform_version: &PlatformVersion, + ) -> Result { + validate_token_tradeable(self.base()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs new file mode 100644 index 00000000000..9ff10c6f352 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_order_common/mod.rs @@ -0,0 +1,69 @@ +//! Stateful checks shared by the token marketplace validators: the token must be tradeable and +//! an identity whose tokens a listing moves must not be frozen. Every read is metered into the +//! execution context, so the batch owner pays for it. + +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::token::{IdentityTokenAccountFrozenError, TokenNotTradeableError}; +use dpp::consensus::ConsensusError; +use dpp::prelude::Identifier; +use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{ + TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0, +}; + +/// The token's configuration must allow trading (`marketplaceRules.tradeMode` `Tradeable`). A +/// token whose trade mode changes back to `NotTradeable` keeps its open orders, which can still +/// be cancelled but no longer filled. +pub(crate) fn validate_token_tradeable( + base: &TokenBaseTransitionAction, +) -> Result { + if !base.token_configuration()?.is_tradeable() { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenNotTradeableError::new(base.token_id()).into(), + )); + } + Ok(SimpleConsensusValidationResult::new()) +} + +/// The token account of `identity_id` must not be frozen. +#[allow(clippy::too_many_arguments)] +pub(crate) fn validate_token_account_not_frozen( + platform: &PlatformStateRef, + token_id: Identifier, + identity_id: Identifier, + action: &str, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + let (info, fee_result) = platform.drive.fetch_identity_token_info_with_costs( + token_id.to_buffer(), + identity_id.to_buffer(), + block_info, + true, + transaction, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if info.is_some_and(|info| info.frozen()) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError( + IdentityTokenAccountFrozenError::new(token_id, identity_id, action.to_string()), + )), + )); + } + Ok(SimpleConsensusValidationResult::new()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs new file mode 100644 index 00000000000..7ccf94b1264 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/mod.rs @@ -0,0 +1,90 @@ +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionAction; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use crate::error::Error; +use crate::error::execution::ExecutionError; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::state_v0::TokenPlaceAskTransitionActionStateValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::structure_v0::TokenPlaceAskTransitionActionStructureValidationV0; +use crate::platform_types::platform::PlatformStateRef; + +mod state_v0; +mod structure_v0; + +pub trait TokenPlaceAskTransitionActionValidation { + /// Checks that need only the contract the action carries and the block, run with the + /// batch's advanced structure so that check_tx refuses a failing transition too. + fn validate_structure( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result; + + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionValidation for TokenPlaceAskTransitionAction { + fn validate_structure( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_structure_validation + { + 0 => self.validate_structure_v0(block_info, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::validate_structure".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + fn validate_state( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .token_place_ask_transition_state_validation + { + 0 => self.validate_state_v0( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs new file mode 100644 index 00000000000..c56e4257c89 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/state_v0/mod.rs @@ -0,0 +1,110 @@ +use dpp::block::block_info::BlockInfo; +use dpp::prelude::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::query::TransactionArg; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::token::IdentityDoesNotHaveEnoughTokenBalanceError; +use dpp::consensus::ConsensusError; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_account_not_frozen; +use crate::execution::validation::state_transition::batch::action_validation::token::token_shielded_pool_common::validate_token_not_paused; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait TokenPlaceAskTransitionActionStateValidationV0 { + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionStateValidationV0 for TokenPlaceAskTransitionAction { + /// A listing needs a token that is not paused and a seller whose account is not frozen and + /// who holds the amount. What needs no read (a tradeable token, the decimals it signs) is + /// checked with the batch's advanced structure. + fn validate_state_v0( + &self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.base().validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_not_paused( + platform, + self.token_id(), + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let validation_result = validate_token_account_not_frozen( + platform, + self.token_id(), + owner_id, + "placeAsk", + block_info, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let balance = platform + .drive + .fetch_identity_token_balance( + self.token_id().to_buffer(), + owner_id.to_buffer(), + transaction, + platform_version, + )? + .unwrap_or_default(); + + execution_context.add_operation(ValidationOperation::RetrieveIdentityTokenBalance); + + if balance < self.amount() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::IdentityDoesNotHaveEnoughTokenBalanceError( + IdentityDoesNotHaveEnoughTokenBalanceError::new( + self.token_id(), + owner_id, + self.amount(), + balance, + "placeAsk".to_string(), + ), + )), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs new file mode 100644 index 00000000000..842c744bad0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/token/token_place_ask_transition_action/structure_v0/mod.rs @@ -0,0 +1,51 @@ +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::TokenNotTradeableError; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use dpp::state_transition::batch_transition::batched_transition::token_order_terms::validate_token_order_terms_for_token; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; + +use crate::error::Error; +use crate::execution::validation::state_transition::batch::action_validation::token::token_order_common::validate_token_tradeable; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait TokenPlaceAskTransitionActionStructureValidationV0 +{ + fn validate_structure_v0( + &self, + block_info: &BlockInfo, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl TokenPlaceAskTransitionActionStructureValidationV0 for TokenPlaceAskTransitionAction { + /// What a listing needs that reads nothing beyond the contract the action carries: a + /// tradeable token, an amount of at least the token's minimum fill, and a worth of at most + /// `i64::MAX` credits at the token's decimals (`validate_token_order_terms_for_token`), both + /// as the token has them now. + fn validate_structure_v0( + &self, + _block_info: &BlockInfo, + _platform_version: &PlatformVersion, + ) -> Result { + let validation_result = validate_token_tradeable(self.base())?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + let token_configuration = self.base().token_configuration()?; + let Some(min_fill) = token_configuration.min_fill() else { + return Ok(SimpleConsensusValidationResult::new_with_error( + TokenNotTradeableError::new(self.base().token_id()).into(), + )); + }; + Ok(validate_token_order_terms_for_token( + self.amount(), + self.price(), + min_fill, + token_configuration.conventions().decimals(), + )) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs index 38cd1318f15..dca35962345 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v2/mod.rs @@ -14,6 +14,8 @@ use drive::state_transition_action::StateTransitionAction; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::token::token_config_update_transition_action::TokenConfigUpdateTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::TokenFillAskTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::TokenPlaceAskTransitionActionValidation; use crate::execution::validation::state_transition::state_transitions::batch::advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; pub(in crate::execution::validation::state_transition::state_transitions::batch) trait DocumentsBatchStateTransitionStructureValidationV2 @@ -40,6 +42,8 @@ impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { /// (`NonTransferableTokenTradeableError`, 10281); /// - a performed `TokenConfigUpdate` must leave its contract within the compiled size cap /// (`DataContractCompiledMaxSizeExceededError`, 10282). + /// - a `TokenPlaceAsk` needs a tradeable token and the token's decimals, and a + /// `TokenFillAsk` a token that is still tradeable. /// /// A failing action bumps the signer's nonce, as every advanced structure failure does. fn validate_advanced_structure_from_state_v2( @@ -71,6 +75,12 @@ impl DocumentsBatchStateTransitionStructureValidationV2 for BatchTransition { TokenTransitionAction::ConfigUpdateAction(config_update_action) => { config_update_action.validate_structure(execution_context, platform_version)? } + TokenTransitionAction::PlaceAskAction(place_ask_action) => { + place_ask_action.validate_structure(block_info, platform_version)? + } + TokenTransitionAction::FillAskAction(fill_ask_action) => { + fill_ask_action.validate_structure(platform_version)? + } _ => continue, }; if !structure_result.is_valid() { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs index cf927c482b2..e96059a2bc9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs @@ -68,7 +68,10 @@ impl TokenPoolNullifiers { | TokenTransitionAction::DestroyFrozenFundsAction(_) | TokenTransitionAction::ConfigUpdateAction(_) | TokenTransitionAction::DirectPurchaseAction(_) - | TokenTransitionAction::SetPriceForDirectPurchaseAction(_) => return None, + | TokenTransitionAction::SetPriceForDirectPurchaseAction(_) + | TokenTransitionAction::PlaceAskAction(_) + | TokenTransitionAction::FillAskAction(_) + | TokenTransitionAction::CancelOrderAction(_) => return None, }, BatchedTransitionAction::DocumentAction(document) => { let base = match document { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs index 4ba8c74a7dd..93b8331172e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs @@ -41,6 +41,9 @@ use crate::execution::validation::state_transition::batch::action_validation::to use crate::execution::validation::state_transition::batch::action_validation::token::token_burn_from_pool_transition_action::TokenBurnFromPoolTransitionActionValidation; use crate::execution::validation::state_transition::batch::action_validation::token::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionActionValidation; use crate::execution::validation::state_transition::batch::action_validation::token::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_cancel_order_transition_action::TokenCancelOrderTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_fill_ask_transition_action::TokenFillAskTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_place_ask_transition_action::TokenPlaceAskTransitionActionValidation; use crate::execution::validation::state_transition::batch::data_triggers::{data_trigger_bindings_list, DataTriggerExecutionContext, DataTriggerExecutor}; use crate::execution::validation::state_transition::batch::state::v0::accepted_token_pool_nullifiers::{AcceptedTokenPoolNullifiers, TokenPoolNullifiers}; use crate::execution::validation::state_transition::batch::state::v0::added_moderator_cap::AddedModeratorCap; @@ -400,6 +403,36 @@ impl DocumentsBatchStateTransitionStateValidationV0 for BatchTransition { transaction, platform_version, )?, + // The token marketplace arms are reached only from protocol version 15: the + // batch's `is_allowed` gate refuses every order kind before it. + TokenTransitionAction::PlaceAskAction(place_ask_action) => place_ask_action + .validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?, + TokenTransitionAction::FillAskAction(fill_ask_action) => fill_ask_action + .validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?, + TokenTransitionAction::CancelOrderAction(cancel_order_action) => { + cancel_order_action.validate_state( + platform, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )? + } }, BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs new file mode 100644 index 00000000000..c8c631d4a77 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/marketplace/mod.rs @@ -0,0 +1,1906 @@ +//! Token marketplace listings (protocol version 15): a holder of a tradeable token lists an +//! amount for sale (`PlaceAsk`), which moves the tokens into escrow and records an `ask` in the +//! token market contract; a buyer takes part or all of it (`FillAsk`), paying the seller in +//! credits; the seller withdraws what is left (`CancelOrder`). +use super::*; +use crate::execution::check_tx::CheckTxLevel; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::accessors::v1::DataContractV1Setters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::{ + TokenConfigurationConventionV0Getters, TokenConfigurationConventionV0Setters, +}; +use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use dpp::data_contract::associated_token::token_keeps_history_rules::accessors::v0::TokenKeepsHistoryRulesV0Setters; +use dpp::data_contract::associated_token::token_marketplace_rules::accessors::v0::TokenMarketplaceRulesV0Setters; +use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::group::GroupStateTransitionInfo; +use dpp::prelude::{Identity, IdentityPublicKey}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; +use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; +use dpp::state_transition::proof_result::{ + StateTransitionProofGuarantee, StateTransitionProofOutcome, StateTransitionProofResult, +}; +use dpp::state_transition::StateTransition; +use dpp::system_data_contracts::SystemDataContract; +use dpp::tokens::calculate_token_id; +use dpp::tokens::emergency_action::TokenEmergencyAction; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use drive::drive::Drive; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionActionV0; +use drive::error::proof::ProofError; +use drive::error::Error as DriveError; +use simple_signer::signer::SimpleSigner; +use std::sync::Arc; + +/// 10 credits per base unit of a token with 8 decimals. +const PRICE: Credits = 1_000_000_000; + +/// The basic token contract gives its owner this many base units. +const BASE_SUPPLY: u64 = 100_000; + +fn make_token_tradeable(token_configuration: &mut TokenConfiguration) { + make_token_tradeable_with_min_fill(token_configuration, 1); +} + +/// The token is tradeable, its orders filled `min_fill` base units at least. +fn make_token_tradeable_with_min_fill(token_configuration: &mut TokenConfiguration, min_fill: u64) { + token_configuration + .as_v0_mut() + .marketplace_rules + .set_trade_mode(TokenTradeMode::Tradeable { min_fill }); +} + +/// A tradeable market whose token's orders are filled 100 base units at least. +fn market_with_min_fill_100() -> Market { + market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable_with_min_fill(token_configuration, 100) + }, + dash_to_credits!(1.0), + ) +} + +fn make_token_tradeable_and_pausable(token_configuration: &mut TokenConfiguration) { + make_token_tradeable(token_configuration); + token_configuration.set_emergency_action_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + })); +} + +struct Party { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The next identity contract nonce of this party for the token's contract. + nonce: u64, +} + +impl Party { + fn id(&self) -> Identifier { + self.identity.id() + } + + fn take_nonce(&mut self) -> u64 { + let nonce = self.nonce; + self.nonce += 1; + nonce + } +} + +struct Market { + platform: TempPlatform, + contract: DataContract, + token_id: Identifier, + /// The contract owner, who holds the whole base supply. + seller: Party, + buyer: Party, +} + +fn market_with( + token_configuration_modification: impl FnOnce(&mut TokenConfiguration), + buyer_credits: Credits, +) -> Market { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(70415); + let (seller, seller_signer, seller_key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(1.0)); + let (buyer, buyer_signer, buyer_key) = setup_identity(&mut platform, rng.gen(), buyer_credits); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + seller.id(), + Some(token_configuration_modification), + None, + None, + None, + platform_version, + ); + + Market { + platform, + contract, + token_id, + seller: Party { + identity: seller, + signer: seller_signer, + key: seller_key, + nonce: 2, + }, + buyer: Party { + identity: buyer, + signer: buyer_signer, + key: buyer_key, + nonce: 2, + }, + } +} + +/// The guarantee the proof of a marketplace transition gives. A placement's binds its +/// execution: the order's document carries an id only this transition derives, and its terms, +/// also once fills have changed what is left. A fill leaves a balance snapshot and a cancel an +/// absence, which the order being filled entirely would leave too, so their proofs only +/// authenticate the state. +fn marketplace_proof_guarantee( + transition: &StateTransition, +) -> Option { + let StateTransition::Batch(batch) = transition else { + return None; + }; + match batch.first_transition()? { + BatchedTransitionRef::Token(TokenTransition::PlaceAsk(_)) => { + Some(StateTransitionProofGuarantee::ExecutionProved) + } + BatchedTransitionRef::Token( + TokenTransition::FillAsk(_) | TokenTransition::CancelOrder(_), + ) => Some(StateTransitionProofGuarantee::AffectedState), + _ => None, + } +} + +/// The verified result, once the proof's guarantee is the one `claimed` earns. +fn checked_result( + claimed: &StateTransition, + outcome: StateTransitionProofOutcome, +) -> StateTransitionProofResult { + if let Some(expected) = marketplace_proof_guarantee(claimed) { + assert_eq!( + outcome.guarantee(), + expected, + "the proof's guarantee for this marketplace transition" + ); + } + outcome.into_result() +} + +fn tradeable_market() -> Market { + market_with(make_token_tradeable, dash_to_credits!(1.0)) +} + +fn at_time(time_ms: u64) -> BlockInfo { + BlockInfo { + time_ms, + ..BlockInfo::default() + } +} + +impl Market { + fn process_at( + &mut self, + transition: &StateTransition, + block_info: &BlockInfo, + ) -> StateTransitionsProcessingResult { + self.process_in_one_block(&[transition], block_info) + } + + /// Processes `transitions` in order in one block transaction, committed after the last. + fn process_in_one_block( + &mut self, + transitions: &[&StateTransition], + block_info: &BlockInfo, + ) -> StateTransitionsProcessingResult { + let platform_version = PlatformVersion::latest(); + let serialized: Vec> = transitions + .iter() + .map(|transition| { + transition + .serialize_to_bytes() + .expect("expected the batch to serialize") + }) + .collect(); + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &serialized, + &platform_state, + block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the batch"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the batch"); + processing_result + } + + /// What check_tx refuses `transition` with. + fn check_tx(&self, transition: &StateTransition) -> Vec { + let platform_version = PlatformVersion::latest(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch to serialize"); + let platform_state = self.platform.state.load(); + let platform_ref = PlatformRef { + drive: &self.platform.drive, + state: &platform_state, + config: &self.platform.config, + core_rpc: &self.platform.core_rpc, + }; + self.platform + .check_tx( + &serialized, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("expected to be able to check tx") + .errors + } + + fn process(&mut self, transition: &StateTransition) -> StateTransitionsProcessingResult { + self.process_at(transition, &BlockInfo::default()) + } + + fn assert_executes(&mut self, transition: &StateTransition, block_info: &BlockInfo) { + let processing_result = self.process_at(transition, block_info); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "expected the batch to execute: {:?}", + processing_result.execution_results() + ); + } + + /// A listing by the seller, and its order id. + async fn place_ask(&mut self, amount: u64, price: Credits) -> (StateTransition, Identifier) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.seller.id(), nonce); + let transition = BatchTransition::new_token_place_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + amount, + price, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing"); + (transition, order_id) + } + + /// A fill by the buyer. + async fn fill_ask( + &mut self, + order_id: Identifier, + min_amount: u64, + max_amount: u64, + max_total_price: Credits, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.buyer.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.buyer.id(), + self.contract.id(), + 0, + order_id, + min_amount, + max_amount, + max_total_price, + &self.buyer.key, + nonce, + 0, + &self.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + /// A fill by the seller of their own listing. + async fn seller_fill_ask(&mut self, order_id: Identifier) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + order_id, + 1, + u64::MAX >> 1, + u64::MAX >> 1, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + /// A cancel by the seller, or by the buyer when `by_buyer`. + async fn cancel(&mut self, order_id: Identifier, by_buyer: bool) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let party = if by_buyer { + &mut self.buyer + } else { + &mut self.seller + }; + let nonce = party.take_nonce(); + BatchTransition::new_token_cancel_order_transition( + self.token_id, + party.id(), + self.contract.id(), + 0, + order_id, + TokenOrderSide::Ask, + &party.key, + nonce, + 0, + &party.signer, + platform_version, + None, + ) + .await + .expect("expected to build the cancel") + } + + async fn pause(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_emergency_action_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenEmergencyAction::Pause, + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the pause"); + self.assert_executes(&transition, &BlockInfo::default()); + } + + /// The seller, the contract owner, changes the token's decimals. + /// The seller transfers `amount` to the buyer. + async fn transfer_to_buyer(&mut self, amount: u64) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transfer = BatchTransition::new_token_transfer_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + amount, + self.buyer.id(), + None, + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the transfer"); + self.assert_executes(&transfer, &BlockInfo::default()); + } + + /// The seller, the contract owner, freezes the buyer's token account. + async fn freeze_the_buyer(&mut self) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let freeze = BatchTransition::new_token_freeze_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + self.buyer.id(), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the freeze"); + self.assert_executes(&freeze, &BlockInfo::default()); + } + + /// A listing by the buyer, and its order id. + async fn buyer_places_ask( + &mut self, + amount: u64, + price: Credits, + ) -> (StateTransition, Identifier) { + let platform_version = PlatformVersion::latest(); + let nonce = self.buyer.take_nonce(); + let order_id = TokenOrderSide::Ask.order_id(self.token_id, self.buyer.id(), nonce); + let transition = BatchTransition::new_token_place_ask_transition( + self.token_id, + self.buyer.id(), + self.contract.id(), + 0, + amount, + price, + &self.buyer.key, + nonce, + 0, + &self.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing"); + (transition, order_id) + } + + /// A fill by the seller of up to `max_amount` of the listing `order_id`. + async fn seller_fills_ask(&mut self, order_id: Identifier, max_amount: u64) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + BatchTransition::new_token_fill_ask_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + order_id, + 1, + max_amount, + u64::MAX >> 1, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill") + } + + async fn set_decimals(&mut self, decimals: u8) { + let platform_version = PlatformVersion::latest(); + let mut conventions = self + .contract + .tokens() + .get(&0) + .expect("expected the token") + .conventions() + .clone(); + conventions.set_decimals(decimals); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_config_update_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenConfigurationChangeItem::Conventions(conventions), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the conventions update"); + self.assert_executes(&transition, &BlockInfo::default()); + } + + /// Sets the token's minimum fill to `min_fill`, as the seller, through a configuration + /// update of its trade mode. + async fn set_min_fill(&mut self, min_fill: u64) { + let platform_version = PlatformVersion::latest(); + let nonce = self.seller.take_nonce(); + let transition = BatchTransition::new_token_config_update_transition( + self.token_id, + self.seller.id(), + self.contract.id(), + 0, + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable { + min_fill, + }), + None, + None, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the trade mode update"); + self.assert_executes(&transition, &BlockInfo::default()); + } + + /// A listing by the seller of the token at `position` of the contract `contract_id`, with + /// the seller's identity contract nonce `nonce` for that contract. + async fn place_ask_naming( + &mut self, + contract_id: Identifier, + position: u16, + nonce: u64, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + BatchTransition::new_token_place_ask_transition( + Identifier::from(calculate_token_id(contract_id.as_bytes(), position)), + self.seller.id(), + contract_id, + position, + 1_000, + PRICE, + &self.seller.key, + nonce, + 0, + &self.seller.signer, + platform_version, + None, + ) + .await + .expect("expected to build the listing") + } + + /// The identity contract nonce `identity_id` last used for the token's contract. + fn contract_nonce(&self, identity_id: Identifier) -> Option { + self.platform + .drive + .fetch_identity_contract_nonce( + identity_id.to_buffer(), + self.contract.id().to_buffer(), + true, + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the nonce") + } + + fn token_balance(&self, identity_id: Identifier) -> Option { + self.platform + .drive + .fetch_identity_token_balance( + self.token_id.to_buffer(), + identity_id.to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the token balance") + } + + fn credit_balance(&self, identity_id: Identifier) -> Credits { + self.platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, PlatformVersion::latest()) + .expect("expected to fetch the credit balance") + .expect("expected the identity to have a balance") + } + + fn escrow(&self, order_id: Identifier) -> Option { + self.platform + .drive + .fetch_token_order_escrow( + self.token_id.to_buffer(), + order_id.to_buffer(), + None, + &mut vec![], + PlatformVersion::latest(), + ) + .expect("expected to fetch the escrow") + } + + fn listing(&self, order_id: Identifier) -> Option { + let platform_version = PlatformVersion::latest(); + let token_market_contract = self + .platform + .drive + .token_market_contract_fetch_info(platform_version) + .expect("expected the token market contract"); + let (document, _) = self + .platform + .drive + .fetch_token_order_document( + &token_market_contract.contract, + TokenOrderSide::Ask, + order_id, + &BlockInfo::default().epoch, + None, + platform_version, + ) + .expect("expected to fetch the listing"); + document.map(|document| { + TokenOrder::from_document(&document, TokenOrderSide::Ask) + .expect("expected the listing to read as an order") + }) + } + + fn assert_supply_balanced(&self) { + let total = self + .platform + .drive + .calculate_total_tokens_balance(None, PlatformVersion::latest()) + .expect("expected to calculate the total tokens balance"); + assert!( + total.ok().expect("expected the totals to add up"), + "expected the token supply to stay balanced: {total:?}" + ); + } + + /// Proves the executed transition the way the node answers a wait, and verifies the proof + /// the way the SDK does, returning the verifier's error rather than panicking on it. + /// Proves `proved` the way the node answers a wait and verifies that proof against + /// `claimed`, with the verifier's contract lookup answering `contract`. + fn verify_proof_of_as( + &self, + proved: &StateTransition, + claimed: &StateTransition, + contract: &DataContract, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(proved, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + Drive::verify_state_transition_was_executed_with_proof( + claimed, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .map(|(root_hash, outcome)| { + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); + checked_result(claimed, outcome) + }) + } + + fn prove_and_try_verify( + &self, + transition: &StateTransition, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(self.contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .map(|(root_hash, outcome)| { + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); + checked_result(transition, outcome) + }) + } + + /// Proves the executed transition the way the node answers a wait, and verifies the proof + /// the way the SDK does. + fn prove_and_verify(&self, transition: &StateTransition) -> StateTransitionProofResult { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let contract = Arc::new(self.contract.clone()); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&contract))); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the proof to verify"); + assert_eq!( + root_hash, + self.committed_root(), + "the proof authenticates the committed state" + ); + checked_result(transition, outcome) + } + + /// The root of the state committed so far, which a client authenticates a proof against. + fn committed_root(&self) -> [u8; 32] { + self.platform + .drive + .grove + .root_hash(None, &PlatformVersion::latest().drive.grove_version) + .value + .expect("expected the committed Drive root") + } +} + +#[tokio::test] +async fn should_move_listed_tokens_into_escrow_and_record_the_listing() { + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + let listing = market.listing(order_id).expect("expected the listing"); + assert_eq!( + listing, + TokenOrder { + id: order_id, + side: TokenOrderSide::Ask, + maker_id: market.seller.id(), + token_id: market.token_id, + price: PRICE, + amount: 1_000, + remaining: 1_000, + escrow: 0, + decimals: 8, + } + ); + market.assert_supply_balanced(); + + let StateTransitionProofResult::VerifiedDocuments(documents) = market.prove_and_verify(&place) + else { + panic!("expected the listing's document to be proved"); + }; + let document: &Document = documents + .get(&order_id) + .and_then(Option::as_ref) + .expect("expected the proof to hold the listing"); + assert_eq!( + TokenOrder::from_document(document, TokenOrderSide::Ask).expect("expected an order"), + listing + ); +} + +/// `transition` with group action information on its one token transition, which no executed +/// placement carries: the node refuses it on a placement. +fn with_group_info(transition: &StateTransition) -> StateTransition { + let StateTransition::Batch(BatchTransition::V1(mut batch)) = transition.clone() else { + panic!("expected a batch of token transitions"); + }; + let BatchedTransition::Token(token_transition) = &mut batch.transitions[0] else { + panic!("expected a token transition"); + }; + token_transition + .base_mut() + .set_using_group_info(Some(GroupStateTransitionInfo { + group_contract_position: 0, + action_id: Identifier::from([3; 32]), + action_is_proposer: false, + })); + StateTransition::Batch(BatchTransition::V1(batch)) +} + +/// `transition` with the base of its one token transition changed by `change`. +fn with_base( + transition: &StateTransition, + change: impl FnOnce(&mut TokenBaseTransition), +) -> StateTransition { + let StateTransition::Batch(BatchTransition::V1(mut batch)) = transition.clone() else { + panic!("expected a batch of token transitions"); + }; + let BatchedTransition::Token(token_transition) = &mut batch.transitions[0] else { + panic!("expected a token transition"); + }; + change(token_transition.base_mut()); + StateTransition::Batch(BatchTransition::V1(batch)) +} + +/// A marketplace transition's proof is read by its token id, which must be the one its contract +/// and position derive. The genuine proof of an executed listing does not verify the same +/// request re-signed naming another position of a two-token contract, or another contract, +/// with the same token id: the node refuses such a request, so it never executed. +#[tokio::test] +async fn should_not_prove_a_listing_whose_token_id_its_contract_and_position_do_not_derive() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + let mut two_tokens = market.contract.clone(); + let token = market + .contract + .expected_token_configuration(0) + .expect("expected the token") + .clone(); + two_tokens.add_token(1, token); + + for claimed in [ + with_base(&place, |base| base.set_token_contract_position(1)), + with_base(&place, |base| { + base.set_data_contract_id(Identifier::from([9; 32])) + }), + ] { + assert_matches!( + market.verify_proof_of_as(&place, &claimed, &two_tokens), + Err(DriveError::Proof(ProofError::UnexpectedResultProof(_))) + ); + } + assert_matches!( + market.verify_proof_of_as(&place, &place, &two_tokens), + Ok(StateTransitionProofResult::VerifiedDocuments(documents)) + if documents.get(&order_id).is_some_and(Option::is_some) + ); +} + +/// A placement is never a group action: the node refuses group information on it. A listing +/// that executed, re-sent with group information and otherwise the same, is refused rather +/// than proved by its `ask`; the listing itself still proves. +#[tokio::test] +async fn should_not_prove_a_listing_carrying_group_information() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + assert_matches!( + market.prove_and_try_verify(&with_group_info(&place)), + Err(DriveError::Proof(ProofError::UnexpectedResultProof(_))) + ); + assert_matches!( + market.prove_and_verify(&place), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.get(&order_id).is_some_and(Option::is_some) + ); +} + +/// A client waiting for its listing may ask for the proof after the listing was filled. Its +/// `ask` still proves the placement after a partial fill: the order id is the transition's +/// alone, and the document holds its terms with less open. A listing filled entirely is gone, +/// so its document no longer proves it. +#[tokio::test] +async fn should_prove_a_listing_after_it_is_partly_filled() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + let StateTransitionProofResult::VerifiedDocuments(documents) = market.prove_and_verify(&place) + else { + panic!("expected the listing's document to be proved"); + }; + let order = TokenOrder::from_document( + documents + .get(&order_id) + .and_then(Option::as_ref) + .expect("expected the proof to hold the listing"), + TokenOrderSide::Ask, + ) + .expect("expected an order"); + assert_eq!((order.amount, order.remaining), (1_000, 600)); + + let fill = market.fill_ask(order_id, 1, 600, 6_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_matches!( + market.prove_and_try_verify(&place), + Err(DriveError::Proof(ProofError::IncorrectProof(_))) + ); +} + +#[tokio::test] +async fn should_sell_part_of_a_listing_and_then_the_rest() { + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + let buyer_credits = market.credit_balance(market.buyer.id()); + + // 400 base units at 10 credits each. + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + assert_eq!(market.token_balance(market.buyer.id()), Some(400)); + assert_eq!(market.escrow(order_id), Some(600)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(600) + ); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 4_000, + "the seller receives the price and pays nothing for the fill" + ); + let buyer_paid = buyer_credits - market.credit_balance(market.buyer.id()); + assert!( + buyer_paid > 4_000, + "the buyer pays the price and the fee, paid {buyer_paid}" + ); + market.assert_supply_balanced(); + assert_matches!( + market.prove_and_verify(&fill), + StateTransitionProofResult::VerifiedTokenBalance(buyer_id, 400) if buyer_id == market.buyer.id() + ); + + // A fill may ask for more than is open: it takes what is left. + let fill = market.fill_ask(order_id, 1, 10_000, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None, "a sold-out listing is gone"); + // The seller receives the price, and the storage of the listing they paid for. + let seller_gain = market.credit_balance(market.seller.id()) - seller_credits; + assert!( + seller_gain > 10_000, + "expected the price and the listing's storage refund, got {seller_gain}" + ); + market.assert_supply_balanced(); +} + +/// Two fills of one listing in one block, each asking for up to 700 of its 1,000: the second +/// reads what the first left in the block's transaction, not the committed listing, so the +/// first takes 700 and the second the 300 left, which closes the listing. +#[tokio::test] +async fn should_fill_a_listing_twice_in_one_block() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + + let first = market.fill_ask(order_id, 1, 700, 7_000).await; + let second = market.fill_ask(order_id, 1, 700, 7_000).await; + let processing_result = market.process_in_one_block(&[&first, &second], &BlockInfo::default()); + let [StateTransitionExecutionResult::SuccessfulExecution { .. }, StateTransitionExecutionResult::SuccessfulExecution { + fee_result: closing_fee, + .. + }] = processing_result.execution_results().as_slice() + else { + panic!( + "expected both fills to execute: {:?}", + processing_result.execution_results() + ); + }; + let listing_refund = closing_fee + .fee_refunds + .calculate_refunds_amount_for_identity(market.seller.id()) + .expect("expected the closing fill to refund the seller's listing"); + + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 1_000) + ); + assert_eq!(market.listing(order_id), None, "the second fill closed it"); + assert_eq!(market.escrow(order_id), None); + // 700 and 300 base units at 10 credits each, and the listing's storage back. + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 10_000 + listing_refund + ); + market.assert_supply_balanced(); +} + +/// 1.5 credits per base unit: two fills of 3 cost 5 and 4, the 9 the 6 units are worth, as +/// each fill pays the change in the listing's rounded-up running total. The listing keeps one +/// unit open, so no storage refund mixes into the seller's balance. +#[tokio::test] +async fn should_charge_each_fill_the_change_in_the_running_total() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(7, 150_000_000).await; + market.assert_executes(&place, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 3, 3, 5).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 5 + ); + + // The second half costs 4, so a limit of 4 is enough. + let fill = market.fill_ask(order_id, 3, 3, 4).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 9 + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(6)); + assert_eq!(market.escrow(order_id), Some(1)); +} + +/// 0.5 credits per base unit: of a listing of 3, the first unit costs 1 credit (its running +/// total, 0.5, rounds up to 1) and the second 0 (the total for 2 units is exactly 1). A fill +/// paying 0 credits, with a limit of 0, still takes its unit out of escrow, advances the buyer's +/// nonce and charges its fee; the seller's credits do not move. The listing keeps one unit open, +/// so no storage refund mixes into the seller's balance. +#[tokio::test] +async fn should_fill_a_unit_that_leaves_the_running_total_unchanged() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(3, 50_000_000).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 1, 1).await; + market.assert_executes(&fill, &BlockInfo::default()); + let seller_credits = market.credit_balance(market.seller.id()); + let buyer_credits = market.credit_balance(market.buyer.id()); + let buyer_nonce = market.contract_nonce(market.buyer.id()); + + let fill = market.fill_ask(order_id, 1, 1, 0).await; + let processing_result = market.process(&fill); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!( + "expected the fill to execute: {:?}", + processing_result.execution_results() + ); + }; + assert!(fee_result.processing_fee > 0); + let refund = fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.buyer.id()) + .unwrap_or_default(); + assert_eq!( + market.credit_balance(market.buyer.id()) as i128, + buyer_credits as i128 - fee_result.storage_fee as i128 - fee_result.processing_fee as i128 + + refund as i128, + "the buyer pays the fee and nothing for the unit" + ); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!(market.token_balance(market.buyer.id()), Some(2)); + assert_eq!(market.escrow(order_id), Some(1)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(1) + ); + assert_ne!(market.contract_nonce(market.buyer.id()), buyer_nonce); + market.assert_supply_balanced(); +} + +/// A listing of more base units than a signed 8-byte value holds is refused by the transition's +/// structure, by check_tx and in a block, before anything is charged, and nothing moves. +#[tokio::test] +async fn should_refuse_a_listing_amount_above_i64_max() { + let mut market = tradeable_market(); + let seller_credits = market.credit_balance(market.seller.id()); + let (place, order_id) = market.place_ask(i64::MAX as u64 + 1, 1).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenAmountError(_) + )] + ); + assert_matches!( + market.process(&place).execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_)) + )] + ); + assert_eq!(market.credit_balance(market.seller.id()), seller_credits); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +/// Every integer of an order is a signed 8-byte value, but its `$revision` is stored as a +/// varint: the partial fill that takes a listing from revision 127 to 128 grows the `ask` by a +/// byte, which that fill's buyer pays for. The fills before it, and the ones after, add no +/// storage. The listing stays its seller's: when it closes, its storage is refunded to the +/// seller alone. The token keeps no transfer history, so a fill records nothing else and only +/// the `ask` can add storage. +#[tokio::test] +async fn should_charge_the_fill_that_widens_a_listings_revision() { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration + .keeps_history_mut() + .set_keeps_transfer_history(false); + }, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(200, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let mut fills_adding_storage = vec![]; + for fill_number in 1..=130u64 { + let fill = market.fill_ask(order_id, 1, 1, 10).await; + let processing_result = market.process(&fill); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!( + "expected fill {fill_number} to execute: {:?}", + processing_result.execution_results() + ); + }; + // The first fill also creates the buyer's token balance. + if fill_number > 1 && fee_result.storage_fee > 0 { + fills_adding_storage.push(fill_number); + } + } + // The listing was created at revision 1, so fill 127 takes it to 128. + assert_eq!(fills_adding_storage, vec![127]); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(70) + ); + + let cancel = market.cancel(order_id, false).await; + let processing_result = market.process(&cancel); + let [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] = + processing_result.execution_results().as_slice() + else { + panic!("expected the cancel to execute"); + }; + assert!( + fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.seller.id()) + .is_some_and(|refund| refund > 0), + "the seller gets the listing's storage back" + ); + assert_eq!( + fee_result + .fee_refunds + .calculate_refunds_amount_for_identity(market.buyer.id()), + None, + "the buyer who paid for the wider revision is not refunded: the listing is the seller's" + ); +} + +#[tokio::test] +async fn should_return_the_tokens_of_a_cancelled_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + let fill = market.fill_ask(order_id, 1, 250, 2_500).await; + market.assert_executes(&fill, &BlockInfo::default()); + + let cancel = market.cancel(order_id, false).await; + market.assert_executes(&cancel, &BlockInfo::default()); + + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 250) + ); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); + market.assert_supply_balanced(); + assert_matches!( + market.prove_and_verify(&cancel), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.len() == 1 && documents.get(&order_id) == Some(&None) + ); +} + +/// What deriving a listing's id costs: a double SHA-256 over its 75-byte preimage (the token +/// id, the seller's id, "ask" and the nonce), 2 blocks hashed and the second SHA-256 a third. +fn listing_id_hash_fee() -> Credits { + let hashing = &PlatformVersion::latest().fee_version.hashing; + hashing.single_sha256_base + 3 * hashing.sha256_per_block +} + +/// The placement's transformation derives the listing's id and charges the hash: for a token +/// outside a group it reads nothing else, so the hash is all it charges. A placement naming a +/// token the contract does not define is refused before the id is derived and charges nothing. +#[tokio::test] +async fn should_charge_a_listing_the_hash_of_its_id_where_it_is_derived() { + let market = tradeable_market(); + let platform_version = PlatformVersion::latest(); + let contract = market + .platform + .drive + .get_contract_with_fetch_info( + market.contract.id().to_buffer(), + false, + None, + platform_version, + ) + .expect("expected to fetch the contract") + .expect("expected the token's contract"); + let transform = |position: u16| { + let transition = TokenPlaceAskTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 2, + token_contract_position: position, + data_contract_id: market.contract.id(), + token_id: Identifier::from(calculate_token_id( + market.contract.id().as_bytes(), + position, + )), + using_group_info: None, + }), + amount: 1_000, + price: PRICE, + }; + TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( + &market.platform.drive, + market.seller.id(), + &transition, + false, + None, + &BlockInfo::default(), + 0, + |_| Ok(Arc::clone(&contract)), + platform_version, + ) + .expect("expected the transformation to run") + }; + + let (placed, fee) = transform(0); + assert!(placed.is_valid(), "{:?}", placed.errors); + assert_eq!(fee.processing_fee, listing_id_hash_fee()); + + let (refused, fee) = transform(1); + assert_matches!( + refused.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenPositionError(_) + )] + ); + assert_eq!(fee.processing_fee, 0, "the id was never derived"); +} + +/// A placement refused after its id is derived (a token that is not tradeable, refused by the +/// batch's advanced structure) pays for the hash on top of what one refused before the +/// derivation (a token the contract does not define) pays. A first refusal creates the seller's +/// nonce entry for the contract, so the two compared both update it. +#[tokio::test] +async fn should_charge_a_listing_refused_after_its_id_is_derived_the_hash() { + let mut market = market_with(|_| {}, dash_to_credits!(1.0)); + let contract_id = market.contract.id(); + let mut refuse = async |market: &mut Market, position: u16| { + let nonce = market.seller.take_nonce(); + let place = market.place_ask_naming(contract_id, position, nonce).await; + let processing_result = market.process(&place); + match processing_result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error, actual_fees, .. + }] => (error.clone(), actual_fees.processing_fee), + results => panic!("expected a paid refusal: {results:?}"), + } + }; + + refuse(&mut market, 1).await; + let (error, refused_before) = refuse(&mut market, 1).await; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)) + ); + let (error, refused_after) = refuse(&mut market, 0).await; + assert_matches!( + error, + ConsensusError::StateError(StateError::TokenNotTradeableError(_)) + ); + assert_eq!(refused_after, refused_before + listing_id_hash_fee()); +} + +#[tokio::test] +async fn should_refuse_listing_a_token_that_is_not_tradeable() { + let mut market = market_with(|_| {}, dash_to_credits!(1.0)); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + + // The rule reads only the contract the action carries, so the mempool refuses it too. + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::StateError( + StateError::TokenNotTradeableError(_) + )] + ); + + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::StateError(StateError::TokenNotTradeableError(inner)), + .. + }] if inner.token_id() == market.token_id && error.code() == 40750 + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +/// The contract owner (the seller) may change the token's conventions and freeze accounts. +fn make_token_tradeable_with_owner_conventions_and_freezes( + token_configuration: &mut TokenConfiguration, +) { + make_token_tradeable(token_configuration); + let owner_rules = ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }); + token_configuration.set_conventions_change_rules(owner_rules.clone()); + token_configuration.set_freeze_rules(owner_rules); +} + +/// A listing of less than the token's minimum fill would open holding less than a trade: it is +/// refused with `InvalidTokenOrderFillRangeError` (the minimum, then the amount), by check_tx +/// and in a block, and nothing moves into escrow. A listing of exactly the minimum is placed. +#[tokio::test] +async fn should_refuse_a_listing_of_less_than_the_tokens_minimum_fill() { + let mut market = market_with_min_fill_100(); + let (listing, order_id) = market.place_ask(99, PRICE).await; + + assert_matches!( + market.check_tx(&listing).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(error))] + if error.min() == 100 && error.max() == 99 + ); + assert_matches!( + market.process(&listing).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderFillRangeError(_)), + .. + }] if error.code() == 10463 + ); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); + + let (listing, order_id) = market.place_ask(100, PRICE).await; + market.assert_executes(&listing, &BlockInfo::default()); + assert_eq!(market.escrow(order_id), Some(100)); +} + +/// The contract owner (the seller) may change the token's trade mode, and with it the minimum +/// fill. +fn make_token_tradeable_with_owner_trade_mode(token_configuration: &mut TokenConfiguration) { + make_token_tradeable(token_configuration); + token_configuration + .as_v0_mut() + .marketplace_rules + .set_trade_mode_change_rules(ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + })); +} + +/// Every fill reads the token's minimum fill as the token has it then: a listing of 1,000 +/// placed at a minimum of 1 is, once the minimum is 600, no longer sold 500 at a time (500 +/// would take less than 600, and leave less), but is still sold whole. +#[tokio::test] +async fn should_fill_open_listings_by_the_tokens_current_minimum_fill() { + let mut market = market_with( + make_token_tradeable_with_owner_trade_mode, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + market.set_min_fill(600).await; + + let fill = market.fill_ask(order_id, 1, 500, 1_000_000).await; + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderFillAmountError(error)), + .. + }] if error.min_fill() == 600 + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + + let fill = market.fill_ask(order_id, 1, 1_000, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!(market.token_balance(market.buyer.id()), Some(1_000)); + assert_eq!(market.listing(order_id), None); +} + +/// A listing whose maker's account is frozen cannot be filled: the fill is refused with the +/// maker's `IdentityTokenAccountFrozenError` and nothing moves. The frozen maker can still +/// cancel it, and gets back what it holds in escrow. +#[tokio::test] +async fn should_refuse_filling_a_frozen_makers_listing_and_let_the_maker_cancel() { + let mut market = market_with( + make_token_tradeable_with_owner_conventions_and_freezes, + dash_to_credits!(1.0), + ); + market.transfer_to_buyer(5_000).await; + let (place, order_id) = market.buyer_places_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + market.freeze_the_buyer().await; + + let fill = market.seller_fills_ask(order_id, 400).await; + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::IdentityTokenAccountFrozenError(error)), + .. + }] if *error.identity_id() == market.buyer.id() && *error.token_id() == market.token_id + ); + assert_eq!(market.escrow(order_id), Some(1_000)); + assert_eq!(market.token_balance(market.buyer.id()), Some(4_000)); + assert_eq!( + market.token_balance(market.seller.id()), + Some(BASE_SUPPLY - 5_000) + ); + + let cancel = market.cancel(order_id, true).await; + market.assert_executes(&cancel, &BlockInfo::default()); + assert_eq!(market.escrow(order_id), None); + assert_eq!(market.listing(order_id), None); + assert_eq!(market.token_balance(market.buyer.id()), Some(5_000)); +} + +/// A buyer whose account is frozen cannot buy a listing unless the token allows transfers to +/// frozen balances: then the fill executes and the tokens land in the frozen balance. +#[tokio::test] +async fn should_refuse_a_frozen_buyers_fill_unless_the_token_allows_transfers_to_frozen_balances() { + for allowed in [false, true] { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable_with_owner_conventions_and_freezes(token_configuration); + token_configuration.allow_transfer_to_frozen_balance(allowed); + }, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + market.freeze_the_buyer().await; + + let fill = market.fill_ask(order_id, 1, 400, 4_000).await; + let processing_result = market.process(&fill); + if allowed { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(400)); + assert_eq!(market.escrow(order_id), Some(600)); + } else { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityTokenAccountFrozenError(error) + ), + .. + }] if *error.identity_id() == market.buyer.id() + ); + assert_eq!(market.token_balance(market.buyer.id()), None); + assert_eq!(market.escrow(order_id), Some(1_000)); + } + } +} + +#[tokio::test] +async fn should_refuse_listing_more_than_the_seller_holds() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(BASE_SUPPLY + 1, PRICE).await; + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::IdentityDoesNotHaveEnoughTokenBalanceError(_) + ), + .. + }] + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +/// What a listing is worth at the token's decimals is checked with the batch's advanced +/// structure, which knows the token: two whole tokens at `i64::MAX` credits each are refused by +/// check_tx, and in a block for a fee, and no token moves. +#[tokio::test] +async fn should_refuse_a_listing_worth_more_than_an_order_holds() { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration.conventions_mut().set_decimals(0); + }, + dash_to_credits!(1.0), + ); + let seller_credits = market.credit_balance(market.seller.id()); + let (place, order_id) = market.place_ask(2, i64::MAX as u64).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(error))] + if error.amount() == 2 && error.decimals() == 0 + ); + let processing_result = market.process(&place); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: error @ ConsensusError::BasicError(BasicError::InvalidTokenOrderValueError(_)), + .. + }] if error.code() == 10465 + ); + assert!(market.credit_balance(market.seller.id()) < seller_credits); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); + assert_eq!(market.listing(order_id), None); +} + +#[tokio::test] +async fn should_refuse_filling_ones_own_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.seller_fill_ask(order_id).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderSelfFillError(_)), + .. + }] + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +#[tokio::test] +async fn should_refuse_a_fill_above_the_buyers_price_limit() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.fill_ask(order_id, 1, 400, 3_999).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderPriceLimitError(inner)), + .. + }] if inner.total() == 4_000 && inner.limit() == 3_999 + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +/// A listing of 1,000 of a token whose minimum fill is 100: a fill of 950 would leave 50 open, +/// so the fill takes 900 instead, which a buyer asking for at least 950 does not accept. +#[tokio::test] +async fn should_refuse_a_fill_the_minimum_fill_cannot_meet() { + let mut market = market_with_min_fill_100(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let fill = market.fill_ask(order_id, 950, 950, 1_000_000).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderFillAmountError(_)), + .. + }] + ); + + // Accepting 900 is enough. + let fill = market.fill_ask(order_id, 900, 950, 1_000_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!(market.token_balance(market.buyer.id()), Some(900)); + assert_eq!(market.escrow(order_id), Some(100)); +} + +#[tokio::test] +async fn should_refuse_filling_a_listing_that_does_not_exist() { + let mut market = tradeable_market(); + let fill = market + .fill_ask(Identifier::from([7; 32]), 1, 1_000, 1_000_000) + .await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderNotFoundError(_)), + .. + }] + ); +} + +#[tokio::test] +async fn should_refuse_cancelling_someone_elses_listing() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let cancel = market.cancel(order_id, true).await; + let processing_result = market.process(&cancel); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenOrderNotOwnedError(_)), + .. + }] + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} + +#[tokio::test] +async fn should_refuse_a_fill_of_a_paused_token_but_still_cancel() { + let mut market = market_with(make_token_tradeable_and_pausable, dash_to_credits!(1.0)); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + market.pause().await; + + let fill = market.fill_ask(order_id, 1, 1_000, 10_000).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::TokenIsPausedError(_)), + .. + }] + ); + + let cancel = market.cancel(order_id, false).await; + market.assert_executes(&cancel, &BlockInfo::default()); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); +} + +/// The batch's balance check counts what a fill pays: a buyer whose credits cover the price but +/// not the fee, and one whose credits do not cover the price, are both refused before anything +/// is charged, and neither the buyer, the listing nor the buyer's nonce changes. +#[tokio::test] +async fn should_not_fill_a_listing_the_buyer_cannot_pay_for() { + let mut market = market_with(make_token_tradeable, dash_to_credits!(0.0001)); + let buyer_credits = market.credit_balance(market.buyer.id()); + // 10,000,000 credits for the whole listing: the buyer's balance, short of the fee. + let (place, at_balance) = market.place_ask(1_000, 1_000_000_000_000).await; + market.assert_executes(&place, &BlockInfo::default()); + // 20,000,000 credits: twice the buyer's balance. + let (place, above_balance) = market.place_ask(1_000, 2_000_000_000_000).await; + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!(buyer_credits, 10_000_000); + let buyer_nonce = market.contract_nonce(market.buyer.id()); + + for order_id in [at_balance, above_balance] { + let fill = market.fill_ask(order_id, 1, 1_000, u64::MAX >> 1).await; + let processing_result = market.process(&fill); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(_)) + )], + "a fill the buyer cannot pay for is refused unpaid: {:?}", + processing_result.execution_results() + ); + assert_eq!(market.credit_balance(market.buyer.id()), buyer_credits); + assert_eq!(market.contract_nonce(market.buyer.id()), buyer_nonce); + assert_eq!(market.token_balance(market.buyer.id()), None); + assert_eq!(market.escrow(order_id), Some(1_000)); + assert_eq!( + market.listing(order_id).map(|listing| listing.remaining), + Some(1_000) + ); + } +} + +/// A listing records the decimals its token had when it was placed and every fill prices it at +/// them. The token changes from 8 decimals to 2 between two fills: priced at 2, 100 base units +/// of the listing would cost 1,000,000,000 credits, and the buyer's limit of 1,000 would refuse +/// the fill; priced at the listing's 8, they cost 1,000. +#[tokio::test] +async fn should_keep_pricing_a_listing_at_the_decimals_it_was_placed_at() { + let mut market = market_with( + |token_configuration: &mut TokenConfiguration| { + make_token_tradeable(token_configuration); + token_configuration.set_conventions_change_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::ContractOwner, + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }, + dash_to_credits!(1.0), + ); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!( + market.listing(order_id).map(|listing| listing.decimals), + Some(8) + ); + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 100, 100, 1_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 1_000 + ); + + market.set_decimals(2).await; + let seller_credits = market.credit_balance(market.seller.id()); + + let fill = market.fill_ask(order_id, 100, 100, 1_000).await; + market.assert_executes(&fill, &BlockInfo::default()); + assert_eq!( + market.credit_balance(market.seller.id()), + seller_credits + 1_000, + "the fill is priced at the listing's decimals" + ); + assert_eq!(market.token_balance(market.buyer.id()), Some(200)); + let listing = market.listing(order_id).expect("expected the listing"); + assert_eq!((listing.remaining, listing.decimals), (800, 8)); + + // A listing placed after the change records the new decimals. + let (place, newer) = market.place_ask(10, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + assert_eq!( + market.listing(newer).map(|listing| listing.decimals), + Some(2) + ); + market.assert_supply_balanced(); +} + +/// A listing naming a token its contract does not define is refused with the token base +/// rules' error, charged, in check_tx and in a block: a position past the contract's last +/// token, and a contract without tokens (DPNS). +#[tokio::test] +async fn should_refuse_a_listing_of_a_token_the_contract_does_not_define() { + let mut market = tradeable_market(); + let contract_id = market.contract.id(); + let nonce = market.seller.take_nonce(); + let place = market.place_ask_naming(contract_id, 1, nonce).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError(BasicError::InvalidTokenPositionError(error))] + if error.invalid_token_position() == 1 + ); + let processing_result = market.process(&place); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)), + .. + }] + ); + + let dpns_id = SystemDataContract::DPNS.id(); + let place = market.place_ask_naming(dpns_id, 0, 1).await; + assert_matches!( + market.check_tx(&place).as_slice(), + [ConsensusError::BasicError( + BasicError::ContractHasNoTokensError(_) + )] + ); + let processing_result = market.process(&place); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractHasNoTokensError(_)), + .. + }] + ); + assert_eq!(market.token_balance(market.seller.id()), Some(BASE_SUPPLY)); +} + +/// A fill naming a token its contract does not define is refused with the token base rules' +/// error before the listing is read, charged, in check_tx and in a block. +#[tokio::test] +async fn should_refuse_a_fill_of_a_token_the_contract_does_not_define() { + let mut market = tradeable_market(); + let (place, order_id) = market.place_ask(1_000, PRICE).await; + market.assert_executes(&place, &BlockInfo::default()); + + let platform_version = PlatformVersion::latest(); + let nonce = market.buyer.take_nonce(); + let fill = BatchTransition::new_token_fill_ask_transition( + Identifier::from(calculate_token_id(market.contract.id().as_bytes(), 1)), + market.buyer.id(), + market.contract.id(), + 1, + order_id, + 1, + 1_000, + u64::MAX >> 1, + &market.buyer.key, + nonce, + 0, + &market.buyer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the fill"); + assert_matches!( + market.check_tx(&fill).as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidTokenPositionError(_) + )] + ); + assert_matches!( + market.process(&fill).execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::InvalidTokenPositionError(_)), + .. + }] + ); + assert_eq!(market.escrow(order_id), Some(1_000)); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs index aa063c35a84..9722eba58bd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mod.rs @@ -6,6 +6,7 @@ mod direct_selling; mod distribution; mod emergency_action; mod freeze; +mod marketplace; mod mint; mod mint_many; mod non_transferable; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs index 8e1959a5d08..9e953ecf3b5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/non_transferable/mod.rs @@ -635,7 +635,9 @@ mod tradeable { identity.id(), contract.id(), 0, - TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable), + TokenConfigurationChangeItem::MarketplaceTradeMode(TokenTradeMode::Tradeable { + min_fill: 1, + }), None, None, &key, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 7d384fdbb43..a8271b37f5c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -113,6 +113,9 @@ use drive::state_transition_action::batch::batched_transition::token_transition: use drive::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_set_price_for_direct_purchase_transition_action::TokenSetPriceForDirectPurchaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::token_unfreeze_transition_action::TokenUnfreezeTransitionAction; use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; @@ -865,6 +868,39 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(data_contract_fetch_info.clone()) }, platform_version)?; + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + // The token marketplace arms below are refused before they are reached on protocol + // versions 1 through 14: the batch's `is_allowed` gate rejects each order kind unpaid + // until protocol version 15. + TokenTransition::PlaceAsk(token_place_ask) => { + let (batched_action, fee_result) = TokenPlaceAskTransitionAction::try_from_borrowed_token_place_ask_transition_with_contract_lookup(drive, owner_id, token_place_ask, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::FillAsk(token_fill_ask) => { + let (batched_action, fee_result) = TokenFillAskTransitionAction::try_from_borrowed_token_fill_ask_transition_with_contract_lookup(drive, owner_id, token_fill_ask, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::CancelOrder(token_cancel_order) => { + let (batched_action, fee_result) = TokenCancelOrderTransitionAction::try_from_borrowed_token_cancel_order_transition_with_contract_lookup(drive, owner_id, token_cancel_order, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs index 5413aa8c88f..8c52ace9f70 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs @@ -544,7 +544,7 @@ async fn should_keep_registering_a_dotted_summable_name_at_protocol_version_13() #[tokio::test] async fn should_refuse_a_contract_with_a_tradeable_token_unpaid_at_protocol_version_13() { let outcome = register_contract_with( - |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable), + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable { min_fill: 1 }), 13, dash_to_credits!(1.0), ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs index 5f4505a53cd..2826b1d04bf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs @@ -361,7 +361,7 @@ async fn should_refuse_an_update_dropping_unique_items_from_an_index_only_indexe #[tokio::test] async fn should_refuse_an_update_adding_a_tradeable_token_unpaid_at_protocol_version_13() { let outcome = update_fixture_contract( - |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable), + |contract| add_token_with_trade_mode(contract, TokenTradeMode::Tradeable { min_fill: 1 }), 13, ) .await; diff --git a/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs b/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs index 5cad69921fe..56f6010d665 100644 --- a/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/group_queries/group_actions/v0/mod.rs @@ -261,7 +261,10 @@ impl Platform { | TokenEvent::Unshield(..) | TokenEvent::ShieldedTransfer | TokenEvent::ClaimToPool(..) - | TokenEvent::DirectPurchaseToPool(..) => { + | TokenEvent::DirectPurchaseToPool(..) + | TokenEvent::OrderPlaced(..) + | TokenEvent::OrderFilled(..) + | TokenEvent::OrderCancelled(..) => { return None; }, }, diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index 38ea8666225..a7b3dfcfa03 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -157,5 +157,7 @@ verify = [ "dpp/state-transitions", "dpp/system_contracts", "dpp/token-history-contract", + # The token marketplace's proofs read the token market contract's orders. + "dpp/token-market-contract", "dpp/withdrawals-contract", ] diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs new file mode 100644 index 00000000000..ab4a967138a --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/order_escrow/fetch_order.rs @@ -0,0 +1,85 @@ +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; +use crate::drive::Drive; +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::platform_value::Value; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use grovedb_costs::OperationCost; +use std::sync::Arc; + +impl Drive { + /// The token market contract materialized for `platform_version`, wrapped for the document + /// operations of the order transitions. A system contract is stored without storage flags, + /// and the order transitions never bill reading it: it is held by the system contract cache. + pub fn token_market_contract_fetch_info( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let contract = self + .cache + .system_data_contracts + .load_token_market(platform_version)?; + Ok(Arc::new(DataContractFetchInfo { + contract: (*contract).clone(), + storage_flags: None, + cost: OperationCost::default(), + fee: None, + })) + } + + /// The open order `order_id` of `side` in the token market contract, with the processing + /// cost of the read, billed at `epoch`. `None` when no open order of that side has the id: + /// it never existed, was filled entirely or was cancelled. + pub fn fetch_token_order_document( + &self, + token_market_contract: &DataContract, + side: TokenOrderSide, + order_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, u64), Error> { + let document_type = + token_market_contract.document_type_for_name(side.document_type_name())?; + let query = DriveDocumentQuery { + contract: token_market_contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(order_id.to_buffer()), + }), + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let outcome = self.query_documents( + query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + let cost = outcome.cost(); + Ok((outcome.documents_owned().into_iter().next(), cost)) + } +} diff --git a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs index dcebf600541..eb7dae01966 100644 --- a/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs +++ b/packages/rs-drive/src/drive/tokens/order_escrow/mod.rs @@ -7,6 +7,7 @@ mod credits; mod estimated_costs; +mod fetch_order; mod insert_trees; mod tokens; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs index dd5b6122a24..ad70082792c 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/mod.rs @@ -1,5 +1,6 @@ mod token_burn_from_pool_transition; mod token_burn_transition; +mod token_cancel_order_transition; mod token_claim_to_pool_transition; mod token_claim_transition; mod token_config_update_transition; @@ -7,9 +8,11 @@ mod token_destroy_frozen_funds_transition; mod token_direct_purchase_to_pool_transition; mod token_direct_purchase_transition; mod token_emergency_action_transition; +mod token_fill_ask_transition; mod token_freeze_transition; mod token_mint_to_pool_transition; mod token_mint_transition; +mod token_place_ask_transition; mod token_set_price_for_direct_purchase_transition; mod token_shield_transition; mod token_shielded_transfer_transition; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs new file mode 100644 index 00000000000..e49d773f74f --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_cancel_order_transition.rs @@ -0,0 +1,85 @@ +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; + +impl DriveHighLevelBatchOperationConverter for TokenCancelOrderTransitionAction { + /// Gives the maker back what the order holds in escrow and deletes its document, whose + /// storage goes back to the maker. + fn into_high_level_batch_drive_operations<'b>( + self, + _epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_cancel_order_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let order = self.order(); + let side = order.side; + + let mut ops = vec![IdentityOperation( + IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }, + )]; + + match side { + TokenOrderSide::Ask => { + ops.push(TokenOperation( + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id: self.token_id(), + order_id: order.id, + recipient_id: owner_id, + amount: order.remaining, + remaining_after: 0, + }, + )); + } + TokenOrderSide::Bid => { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "an offer cannot be cancelled before offers can be placed", + ))); + } + } + + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: order.id, + contract_info: DataContractInfo::DataContractFetchInfo( + self.token_market_contract().clone(), + ), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + side.document_type_name(), + ), + })); + + Ok(ops) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: + "TokenCancelOrderTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs new file mode 100644 index 00000000000..0e05368062f --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_fill_ask_transition.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; + +impl DriveHighLevelBatchOperationConverter for TokenFillAskTransitionAction { + /// Moves the amount the fill takes from the listing's escrow to the buyer and the credits it + /// costs from the buyer to the seller. A partial fill rewrites the listing's `ask` document; + /// a fill that takes everything open deletes it, and its storage goes back to the seller. + fn into_high_level_batch_drive_operations<'b>( + self, + epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_fill_ask_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let order = self.order(); + let maker_id = order.maker_id; + let order_id = order.id; + let credits = self.credits(); + + let mut ops = vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }), + TokenOperation(TokenOperationType::TokenReleaseFromOrderEscrow { + token_id: self.token_id(), + order_id, + recipient_id: owner_id, + amount: self.fill_amount(), + remaining_after: self.remaining_after(), + }), + ]; + + // The buyer's credits move to the seller, so the seller's balance cannot pass + // `MAX_CREDITS`: no identity holds more than all the credits on Platform, which + // the total credits check keeps far below it (every credit comes from Core, at + // most about 2.1e18 against 9.2e18). A direct purchase pays its contract owner + // the same way. + if credits > 0 { + ops.push(IdentityOperation( + IdentityOperationType::RemoveFromIdentityBalance { + identity_id: owner_id.to_buffer(), + balance_to_remove: credits, + }, + )); + ops.push(IdentityOperation( + IdentityOperationType::AddToIdentityBalance { + identity_id: maker_id.to_buffer(), + added_balance: credits, + }, + )); + } + + let contract_info = + DataContractInfo::DataContractFetchInfo(self.token_market_contract().clone()); + let document_type_info = DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ); + match self.updated_order_document() { + Some(updated_document) => { + let storage_flags = + StorageFlags::new_single_epoch(epoch.index, Some(maker_id.to_buffer())); + ops.push(DocumentOperation(DocumentOperationType::UpdateDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + updated_document.clone(), + Some(Cow::Owned(storage_flags)), + )), + owner_id: Some(maker_id.into_buffer()), + }, + contract_info, + document_type_info, + })); + } + None => { + ops.push(DocumentOperation(DocumentOperationType::DeleteDocument { + document_id: order_id, + contract_info, + document_type_info, + })); + } + } + + Ok(ops) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenFillAskTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs new file mode 100644 index 00000000000..b033fe215bf --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_place_ask_transition.rs @@ -0,0 +1,83 @@ +use std::borrow::Cow; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::tokens::token_order_side::TokenOrderSide; +use platform_version::version::PlatformVersion; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::batch::DriveHighLevelBatchOperationConverter; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; +use crate::util::batch::drive_op_batch::TokenOperationType; +use crate::util::batch::DriveOperation::{DocumentOperation, IdentityOperation, TokenOperation}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; + +impl DriveHighLevelBatchOperationConverter for TokenPlaceAskTransitionAction { + /// Moves the listed tokens from the seller's balance into the listing's escrow and records + /// the listing as an `ask` document of the token market contract, owned and paid for by the + /// seller, who gets its storage back when it closes. + fn into_high_level_batch_drive_operations<'b>( + self, + epoch: &Epoch, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .token_place_ask_transition + { + 0 => { + let data_contract_id = self.base().data_contract_id(); + let identity_contract_nonce = self.base().identity_contract_nonce(); + let token_id = self.token_id(); + let order_id = self.order_id(); + let amount = self.amount(); + let document = self.order_document().clone(); + let storage_flags = + StorageFlags::new_single_epoch(epoch.index, Some(owner_id.to_buffer())); + + Ok(vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: owner_id.into_buffer(), + contract_id: data_contract_id.into_buffer(), + nonce: identity_contract_nonce, + }), + TokenOperation(TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id: owner_id, + order_id, + amount, + }), + DocumentOperation(DocumentOperationType::AddDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + document, + Some(Cow::Owned(storage_flags)), + )), + owner_id: Some(owner_id.into_buffer()), + }, + contract_info: DataContractInfo::DataContractFetchInfo( + self.token_market_contract().clone(), + ), + document_type_info: DocumentTypeInfo::DocumentTypeNameAsStr( + TokenOrderSide::Ask.document_type_name(), + ), + override_document: false, + }), + ]) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "TokenPlaceAskTransitionAction::into_high_level_document_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs index c63d0d0326b..988ac576194 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/token/token_transition.rs @@ -23,6 +23,10 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use crate::state_transition_action::batch::batched_transition::token_transition::token_burn_from_pool_transition_action::TokenBurnFromPoolTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::TokenClaimToPoolTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::TokenPlaceAskTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::TokenCancelOrderTransitionActionAccessorsV0; +use dpp::tokens::token_order_side::TokenOrderSide; use dpp::shielded::serialized_actions_digest; impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { @@ -92,6 +96,14 @@ impl DriveHighLevelBatchOperationConverter for TokenTransitionAction { platform_version, ) } + TokenTransitionAction::PlaceAskAction(place_ask) => { + place_ask.into_high_level_batch_drive_operations(epoch, owner_id, platform_version) + } + TokenTransitionAction::FillAskAction(fill_ask) => { + fill_ask.into_high_level_batch_drive_operations(epoch, owner_id, platform_version) + } + TokenTransitionAction::CancelOrderAction(cancel_order) => cancel_order + .into_high_level_batch_drive_operations(epoch, owner_id, platform_version), } } } @@ -186,6 +198,22 @@ impl TokenTransitionAction { TokenTransitionAction::DirectPurchaseToPoolAction(action) => { TokenEvent::DirectPurchaseToPool(action.token_count(), action.total_agreed_price()) } + TokenTransitionAction::PlaceAskAction(action) => TokenEvent::OrderPlaced( + action.order_id(), + TokenOrderSide::Ask, + action.amount(), + action.price(), + ), + TokenTransitionAction::FillAskAction(action) => TokenEvent::OrderFilled( + action.order().id, + TokenOrderSide::Ask, + action.order().maker_id, + action.fill_amount(), + action.credits(), + ), + TokenTransitionAction::CancelOrderAction(action) => { + TokenEvent::OrderCancelled(action.order().id, action.order().side) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs index 38d4b451267..8801c57fee4 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/mod.rs @@ -50,6 +50,15 @@ pub mod token_claim_to_pool_transition_action; /// Direct purchase paid into the token shielded pool pub mod token_direct_purchase_to_pool_transition_action; +/// A listing of a tradeable token (the token marketplace) +pub mod token_place_ask_transition_action; + +/// A purchase from an open listing (the token marketplace) +pub mod token_fill_ask_transition_action; + +/// A maker cancelling an open order (the token marketplace) +pub mod token_cancel_order_transition_action; + use derive_more::From; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -64,9 +73,13 @@ use dpp::prelude::{ ConsensusValidationResult, DataContract, IdentityNonce, UserFeeIncrease, }; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use dpp::tokens::token_order_side::TokenOrderSide; use dpp::ProtocolError; use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; +use crate::fees::op::{FunctionOp, HashFunction, LowLevelDriveOperation}; use crate::state_transition_action::batch::BatchedTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; @@ -91,6 +104,9 @@ use crate::state_transition_action::batch::batched_transition::token_transition: use crate::state_transition_action::batch::batched_transition::token_transition::token_burn_from_pool_transition_action::{TokenBurnFromPoolTransitionAction, TokenBurnFromPoolTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_claim_to_pool_transition_action::{TokenClaimToPoolTransitionAction, TokenClaimToPoolTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::{TokenDirectPurchaseToPoolTransitionAction, TokenDirectPurchaseToPoolTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionAction, TokenPlaceAskTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionAction, TokenFillAskTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionAction, TokenCancelOrderTransitionActionAccessorsV0}; /// token action #[derive(Debug, Clone, From)] @@ -131,6 +147,12 @@ pub enum TokenTransitionAction { ClaimToPoolAction(TokenClaimToPoolTransitionAction), /// direct purchase paid into the token shielded pool DirectPurchaseToPoolAction(TokenDirectPurchaseToPoolTransitionAction), + /// lists a tradeable token for sale (the token marketplace) + PlaceAskAction(TokenPlaceAskTransitionAction), + /// buys from an open listing (the token marketplace) + FillAskAction(TokenFillAskTransitionAction), + /// cancels an open order (the token marketplace) + CancelOrderAction(TokenCancelOrderTransitionAction), } impl TokenTransitionAction { @@ -155,6 +177,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(action) => action.base(), TokenTransitionAction::ClaimToPoolAction(action) => action.base(), TokenTransitionAction::DirectPurchaseToPoolAction(action) => action.base(), + TokenTransitionAction::PlaceAskAction(action) => action.base(), + TokenTransitionAction::FillAskAction(action) => action.base(), + TokenTransitionAction::CancelOrderAction(action) => action.base(), } } @@ -179,6 +204,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(action) => action.base_owned(), TokenTransitionAction::ClaimToPoolAction(action) => action.base_owned(), TokenTransitionAction::DirectPurchaseToPoolAction(action) => action.base_owned(), + TokenTransitionAction::PlaceAskAction(action) => action.base_owned(), + TokenTransitionAction::FillAskAction(action) => action.base_owned(), + TokenTransitionAction::CancelOrderAction(action) => action.base_owned(), } } @@ -203,6 +231,9 @@ impl TokenTransitionAction { TokenTransitionAction::BurnFromPoolAction(_) => "burnFromPool", TokenTransitionAction::ClaimToPoolAction(_) => "claimToPool", TokenTransitionAction::DirectPurchaseToPoolAction(_) => "directPurchaseToPool", + TokenTransitionAction::PlaceAskAction(_) => "orderPlaced", + TokenTransitionAction::FillAskAction(_) => "orderFilled", + TokenTransitionAction::CancelOrderAction(_) => "orderCancelled", } } @@ -275,6 +306,11 @@ impl TokenTransitionAction { | TokenTransitionAction::BurnFromPoolAction(_) | TokenTransitionAction::ClaimToPoolAction(_) | TokenTransitionAction::DirectPurchaseToPoolAction(_) => Ok(false), + // The marketplace records no token history yet: the open orders themselves are + // documents of the token market contract. + TokenTransitionAction::PlaceAskAction(_) + | TokenTransitionAction::FillAskAction(_) + | TokenTransitionAction::CancelOrderAction(_) => Ok(false), } } } @@ -305,3 +341,33 @@ pub(crate) fn bump_with_errors( fee_result, ) } + +/// The id of the order of \`side\` that \`maker_id\` places for \`token_id\` with the identity +/// contract nonce \`nonce\`, and the fee of deriving it: a double SHA-256 over the id's preimage, +/// charged where the placement's transformation computes it, as a document create's id is. +pub(crate) fn derive_order_id( + drive: &Drive, + side: TokenOrderSide, + token_id: Identifier, + maker_id: Identifier, + nonce: IdentityNonce, + block_info: &BlockInfo, + platform_version: &PlatformVersion, +) -> Result<(Identifier, FeeResult), Error> { + let preimage_size = u16::try_from(side.order_id_preimage_size()).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "an order id's preimage is a few dozen bytes", + )) + })?; + let hash_fee = Drive::calculate_fee( + None, + Some(vec![LowLevelDriveOperation::FunctionOperation( + FunctionOp::new_with_byte_count(HashFunction::Sha256_2, preimage_size), + )]), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + Ok((side.order_id(token_id, maker_id, nonce), hash_fee)) +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs index 64fdfb2956e..2cc15b52ec1 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_base_transition_action/v0/mod.rs @@ -1,5 +1,8 @@ use crate::drive::contract::DataContractFetchInfo; use crate::error::Error; +use dpp::consensus::basic::token::{ContractHasNoTokensError, InvalidTokenPositionError}; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; @@ -60,6 +63,28 @@ pub trait TokenBaseTransitionActionAccessorsV0 { /// Gets the token configuration associated to the action fn token_configuration(&self) -> Result<&TokenConfiguration, Error>; + /// The configuration of the token at the action's position, or the consensus error a + /// transition naming a token its contract does not define is refused with: + /// `ContractHasNoTokensError` when the contract defines no token, `InvalidTokenPositionError` + /// otherwise. The batch's advanced structure validation refuses such a transition with it, + /// and a transformer that needs the token's configuration earlier reads it through this. + fn token_configuration_or_position_error(&self) -> Result<&TokenConfiguration, ConsensusError> { + let contract = &self.data_contract_fetch_info_ref().contract; + let token_position = self.token_position(); + contract.tokens().get(&token_position).ok_or_else(|| { + if contract.tokens().is_empty() { + BasicError::ContractHasNoTokensError(ContractHasNoTokensError::new(contract.id())) + .into() + } else { + BasicError::InvalidTokenPositionError(InvalidTokenPositionError::new( + contract.tokens().keys().last().copied(), + token_position, + )) + .into() + } + }) + } + /// Gets the store_in_group field (optional) fn store_in_group(&self) -> Option<&GroupStateTransitionResolvedInfo>; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs new file mode 100644 index 00000000000..543ea3908d7 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/mod.rs @@ -0,0 +1,45 @@ +use derive_more::From; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// transformer module for the token cancel order transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token cancel order transition action: a maker cancelling an open order (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenCancelOrderTransitionAction { + /// v0 + V0(TokenCancelOrderTransitionActionV0), +} + +impl TokenCancelOrderTransitionActionAccessorsV0 for TokenCancelOrderTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenCancelOrderTransitionAction::V0(v0) => v0.base, + } + } + + fn order(&self) -> &TokenOrder { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.order, + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenCancelOrderTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs new file mode 100644 index 00000000000..f7df0d454f1 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::{TokenCancelOrderTransitionActionV0, TokenCancelOrderTransitionAction}; +use dpp::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenCancelOrderTransitionAction { + /// Transforms a borrowed `TokenCancelOrderTransition` into a `TokenCancelOrderTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenCancelOrderTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenCancelOrderTransition::V0(v0) => { + TokenCancelOrderTransitionActionV0::try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs new file mode 100644 index 00000000000..93ec68fe698 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/mod.rs @@ -0,0 +1,66 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// Token cancel order transition action v0 +#[derive(Debug, Clone)] +pub struct TokenCancelOrderTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The order as it stands before the cancel + pub order: TokenOrder, + /// The token market contract, whose document records the order + pub token_market_contract: Arc, +} + +/// Accessors for `TokenCancelOrderTransitionActionV0` +pub trait TokenCancelOrderTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The order as it stands before the cancel + fn order(&self) -> &TokenOrder; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenCancelOrderTransitionActionAccessorsV0 for TokenCancelOrderTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order(&self) -> &TokenOrder { + &self.order + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..f6f6b6309d7 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_cancel_order_transition_action/v0/transformer.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::{TokenOrderNotFoundError, TokenOrderNotOwnedError}; +use dpp::consensus::ConsensusError; +use dpp::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::TokenCancelOrderTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::token_transition::token_cancel_order_transition_action::v0::TokenCancelOrderTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenCancelOrderTransitionActionV0 { + /// Converts a borrowed `TokenCancelOrderTransitionV0` into an action: reads the order, which + /// must be open for this token and belong to the signer, else the nonce is bumped with the + /// error. A cancel is refused for nothing else: a maker always gets back what an order holds, + /// whether the token is paused, the maker frozen or the trade mode switched off. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_cancel_order_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenCancelOrderTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenCancelOrderTransitionV0 { + base, + order_id, + side, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let mut fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let bump = |errors: Vec, fee_result: FeeResult| { + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, + fee_result, + )) + }; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => return bump(base_action_validation_result.errors, fee_result), + }; + + let token_market_contract = drive.token_market_contract_fetch_info(platform_version)?; + let (order_document, read_cost) = drive.fetch_token_order_document( + &token_market_contract.contract, + *side, + *order_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; + + let Some(order_document) = order_document else { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + }; + let order = TokenOrder::from_document(&order_document, *side)?; + if order.token_id != base.token_id() { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + } + if order.maker_id != owner_id { + return bump( + vec![TokenOrderNotOwnedError::new(*order_id, order.maker_id, owner_id).into()], + fee_result, + ); + } + // Offers are placed from the next generation of the marketplace on: until then no offer + // exists, and the read above finds none. + if *side == TokenOrderSide::Bid { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, *side).into()], + fee_result, + ); + } + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::CancelOrderAction( + TokenCancelOrderTransitionActionV0 { + base: base_action, + order, + token_market_contract, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs new file mode 100644 index 00000000000..b231f3fc7d3 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/mod.rs @@ -0,0 +1,66 @@ +use derive_more::From; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// transformer module for the token fill ask transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token fill ask transition action: a purchase from an open listing (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenFillAskTransitionAction { + /// v0 + V0(TokenFillAskTransitionActionV0), +} + +impl TokenFillAskTransitionActionAccessorsV0 for TokenFillAskTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.base, + } + } + + fn order(&self) -> &TokenOrder { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.order, + } + } + + fn fill_amount(&self) -> TokenAmount { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.fill_amount, + } + } + + fn credits(&self) -> Credits { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.credits, + } + } + + fn updated_order_document(&self) -> Option<&Document> { + match self { + TokenFillAskTransitionAction::V0(v0) => v0.updated_order_document.as_ref(), + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenFillAskTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs new file mode 100644 index 00000000000..18d32f9a811 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::{TokenFillAskTransitionActionV0, TokenFillAskTransitionAction}; +use dpp::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenFillAskTransitionAction { + /// Transforms a borrowed `TokenFillAskTransition` into a `TokenFillAskTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenFillAskTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenFillAskTransition::V0(v0) => { + TokenFillAskTransitionActionV0::try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs new file mode 100644 index 00000000000..a391f9546ca --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/mod.rs @@ -0,0 +1,102 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::tokens::token_order::TokenOrder; +use std::sync::Arc; + +/// Token fill ask transition action v0 +#[derive(Debug, Clone)] +pub struct TokenFillAskTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The listing as it stands before the fill + pub order: TokenOrder, + /// The amount the fill takes, in base units + pub fill_amount: TokenAmount, + /// The credits the buyer pays the seller for it + pub credits: Credits, + /// The listing's document after a partial fill, stamped with the block; `None` when the + /// fill takes everything open and the listing closes + pub updated_order_document: Option, + /// The token market contract, whose `ask` document records the listing + pub token_market_contract: Arc, +} + +/// Accessors for `TokenFillAskTransitionActionV0` +pub trait TokenFillAskTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The listing as it stands before the fill + fn order(&self) -> &TokenOrder; + + /// The amount the fill takes + fn fill_amount(&self) -> TokenAmount; + + /// The credits the buyer pays + fn credits(&self) -> Credits; + + /// The listing's document after a partial fill, `None` when the listing closes + fn updated_order_document(&self) -> Option<&Document>; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// What the listing holds after the fill + fn remaining_after(&self) -> TokenAmount { + self.order().remaining.saturating_sub(self.fill_amount()) + } + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenFillAskTransitionActionAccessorsV0 for TokenFillAskTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order(&self) -> &TokenOrder { + &self.order + } + + fn fill_amount(&self) -> TokenAmount { + self.fill_amount + } + + fn credits(&self) -> Credits { + self.credits + } + + fn updated_order_document(&self) -> Option<&Document> { + self.updated_order_document.as_ref() + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..cef293c610e --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_fill_ask_transition_action/v0/transformer.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::token::{ + TokenOrderFillAmountError, TokenOrderNotFoundError, + TokenOrderPriceLimitError, TokenOrderSelfFillError, +}; +use dpp::consensus::ConsensusError; +use dpp::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::TokenFillAskTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::v0::TokenFillAskTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, TokenTransitionAction}; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenFillAskTransitionActionV0 { + /// Converts a borrowed `TokenFillAskTransitionV0` into an action: reads the listing, and + /// works out the amount the fill takes and the credits the buyer pays. A token its contract + /// does not define, a listing that is not open for this token, the buyer's own listing, a + /// fill range the listing cannot meet and a cost above the buyer's limit + /// each bump the nonce with their error instead. + /// + /// The listing is read as state holds it, and the fill's operations write its new + /// `remaining` and escrow as absolute values. That is sound only while a batch carries one + /// transition (`SystemLimits::max_transitions_in_documents_batch`): two fills of one listing + /// in a batch would both read it unfilled. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_fill_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenFillAskTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenFillAskTransitionV0 { + base, + order_id, + min_amount, + max_amount, + max_total_price, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let mut fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let bump = |errors: Vec, fee_result: FeeResult| { + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, + fee_result, + )) + }; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => return bump(base_action_validation_result.errors, fee_result), + }; + + // Refused with the token base rules' error before anything is read for a token the + // contract does not define. + if let Err(error) = base_action.token_configuration_or_position_error() { + return bump(vec![error], fee_result); + } + + let token_market_contract = drive.token_market_contract_fetch_info(platform_version)?; + let (order_document, read_cost) = drive.fetch_token_order_document( + &token_market_contract.contract, + TokenOrderSide::Ask, + *order_id, + &block_info.epoch, + transaction, + platform_version, + )?; + fee_result.checked_add_assign(FeeResult::new_from_processing_fee(read_cost))?; + + let Some(order_document) = order_document else { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, TokenOrderSide::Ask).into()], + fee_result, + ); + }; + let order = TokenOrder::from_document(&order_document, TokenOrderSide::Ask)?; + if order.token_id != base.token_id() { + return bump( + vec![TokenOrderNotFoundError::new(*order_id, TokenOrderSide::Ask).into()], + fee_result, + ); + } + if order.maker_id == owner_id { + return bump( + vec![TokenOrderSelfFillError::new(*order_id, owner_id).into()], + fee_result, + ); + } + // The token's minimum fill as the token has it now. A token that is no longer tradeable + // has none, and the batch's advanced structure refuses the fill + let min_fill = base_action.token_configuration()?.min_fill().unwrap_or(1); + let Some(fill_amount) = order.fill_amount(*min_amount, *max_amount, min_fill) else { + return bump( + vec![TokenOrderFillAmountError::new( + *order_id, + *min_amount, + *max_amount, + order.remaining, + min_fill, + ) + .into()], + fee_result, + ); + }; + // Priced at the decimals the token had when the listing was placed, which the listing + // records: a later change to the token's decimals does not reprice it. + let credits = order.fill_credits(fill_amount)?; + if credits > *max_total_price { + return bump( + vec![TokenOrderPriceLimitError::new(*order_id, credits, *max_total_price).into()], + fee_result, + ); + } + + let updated_order_document = if fill_amount < order.remaining { + let filled_order = TokenOrder { + remaining: order.remaining - fill_amount, + ..order.clone() + }; + Some(filled_order.updated_document(&order_document, block_info)?) + } else { + None + }; + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::FillAskAction( + TokenFillAskTransitionActionV0 { + base: base_action, + order, + fill_amount, + credits, + updated_order_document, + token_market_contract, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs new file mode 100644 index 00000000000..9d4d12409e2 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/mod.rs @@ -0,0 +1,72 @@ +use derive_more::From; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use std::sync::Arc; + +/// transformer module for the token place ask transition action +pub mod transformer; +mod v0; + +pub use v0::*; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::TokenBaseTransitionAction; + +/// Token place ask transition action: a listing of a tradeable token (protocol version 15) +#[derive(Debug, Clone, From)] +pub enum TokenPlaceAskTransitionAction { + /// v0 + V0(TokenPlaceAskTransitionActionV0), +} + +impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionAction { + fn base(&self) -> &TokenBaseTransitionAction { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.base, + } + } + + fn base_owned(self) -> TokenBaseTransitionAction { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.base, + } + } + + fn order_id(&self) -> Identifier { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.order_id, + } + } + + fn amount(&self) -> TokenAmount { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.amount, + } + } + + fn price(&self) -> Credits { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.price, + } + } + + fn decimals(&self) -> u8 { + match self { + TokenPlaceAskTransitionAction::V0(v0) => v0.decimals, + } + } + + fn order_document(&self) -> &Document { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.order_document, + } + } + + fn token_market_contract(&self) -> &Arc { + match self { + TokenPlaceAskTransitionAction::V0(v0) => &v0.token_market_contract, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs new file mode 100644 index 00000000000..1393d58155b --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/transformer.rs @@ -0,0 +1,53 @@ +use dpp::platform_value::Identifier; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::sync::Arc; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::{TokenPlaceAskTransitionActionV0, TokenPlaceAskTransitionAction}; +use dpp::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransition; +use platform_version::version::PlatformVersion; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenPlaceAskTransitionAction { + /// Transforms a borrowed `TokenPlaceAskTransition` into a `TokenPlaceAskTransitionAction` + /// using the provided data contract lookup. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenPlaceAskTransition, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + match value { + TokenPlaceAskTransition::V0(v0) => { + TokenPlaceAskTransitionActionV0::try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive, + owner_id, + v0, + approximate_without_state_for_costs, + transaction, + block_info, + user_fee_increase, + get_data_contract, + platform_version, + ) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs new file mode 100644 index 00000000000..c70d80d1bac --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/mod.rs @@ -0,0 +1,106 @@ +mod transformer; + +use crate::drive::contract::DataContractFetchInfo; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use dpp::balances::credits::TokenAmount; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use std::sync::Arc; + +/// Token place ask transition action v0 +#[derive(Debug, Clone)] +pub struct TokenPlaceAskTransitionActionV0 { + /// Base token transition action + pub base: TokenBaseTransitionAction, + /// The listing's order id, derived from the token, the seller and the nonce + pub order_id: Identifier, + /// The amount listed, in base units + pub amount: TokenAmount, + /// The credits asked per whole token + pub price: Credits, + /// The token's decimals when the listing is placed, which its price is at and its order + /// records (what the token market contract's `generatedFrom`, `sys.tokens.decimals`, + /// generates) + pub decimals: u8, + /// The listing's `ask` document, stamped with the block that places it + pub order_document: Document, + /// The token market contract, whose `ask` document records the listing + pub token_market_contract: Arc, +} + +/// Accessors for `TokenPlaceAskTransitionActionV0` +pub trait TokenPlaceAskTransitionActionAccessorsV0 { + /// Returns a reference to the base token transition action + fn base(&self) -> &TokenBaseTransitionAction; + + /// Consumes self and returns the base token transition action + fn base_owned(self) -> TokenBaseTransitionAction; + + /// The listing's order id + fn order_id(&self) -> Identifier; + + /// The amount listed + fn amount(&self) -> TokenAmount; + + /// The credits asked per whole token + fn price(&self) -> Credits; + + /// The token's decimals when the listing is placed + fn decimals(&self) -> u8; + + /// The listing's `ask` document + fn order_document(&self) -> &Document; + + /// The token market contract + fn token_market_contract(&self) -> &Arc; + + /// Returns the token position in the contract + fn token_position(&self) -> u16 { + self.base().token_position() + } + + /// Returns the token ID + fn token_id(&self) -> Identifier { + self.base().token_id() + } + + /// Returns a reference to the data contract fetch info of the token's contract + fn data_contract_fetch_info_ref(&self) -> &Arc { + self.base().data_contract_fetch_info_ref() + } +} + +impl TokenPlaceAskTransitionActionAccessorsV0 for TokenPlaceAskTransitionActionV0 { + fn base(&self) -> &TokenBaseTransitionAction { + &self.base + } + + fn base_owned(self) -> TokenBaseTransitionAction { + self.base + } + + fn order_id(&self) -> Identifier { + self.order_id + } + + fn amount(&self) -> TokenAmount { + self.amount + } + + fn price(&self) -> Credits { + self.price + } + + fn decimals(&self) -> u8 { + self.decimals + } + + fn order_document(&self) -> &Document { + &self.order_document + } + + fn token_market_contract(&self) -> &Arc { + &self.token_market_contract + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs new file mode 100644 index 00000000000..c87c09bd5ca --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_place_ask_transition_action/v0/transformer.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; +use grovedb::TransactionArg; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use dpp::identifier::Identifier; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::TokenPlaceAskTransitionV0; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; +use dpp::ProtocolError; +use dpp::consensus::ConsensusError; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; +use platform_version::version::PlatformVersion; +use crate::drive::contract::DataContractFetchInfo; +use crate::drive::Drive; +use crate::error::Error; +use crate::state_transition_action::batch::batched_transition::token_transition::token_base_transition_action::{TokenBaseTransitionAction, TokenBaseTransitionActionAccessorsV0}; +use crate::state_transition_action::batch::batched_transition::token_transition::token_place_ask_transition_action::v0::TokenPlaceAskTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::token_transition::{bump_with_errors, derive_order_id, TokenTransitionAction}; +use crate::state_transition_action::batch::BatchedTransitionAction; + +impl TokenPlaceAskTransitionActionV0 { + /// Converts a borrowed `TokenPlaceAskTransitionV0` into an action. The listing's order id + /// follows from the token, the seller and the nonce, and it records the token's decimals as + /// the token has them now, which price its fills (the token market contract declares them + /// `generatedFrom` `sys.tokens.decimals` of the order's token); a transition naming a token + /// its contract does not define bumps the nonce with `ContractHasNoTokensError` or + /// `InvalidTokenPositionError`. The token's rules (tradeable, the minimum fill, what the + /// listing is worth at the decimals) are checked with the batch's advanced structure, and + /// the seller's balance by the state validation. + #[allow(clippy::too_many_arguments)] + pub fn try_from_borrowed_token_place_ask_transition_with_contract_lookup( + drive: &Drive, + owner_id: Identifier, + value: &TokenPlaceAskTransitionV0, + approximate_without_state_for_costs: bool, + transaction: TransactionArg, + block_info: &BlockInfo, + user_fee_increase: UserFeeIncrease, + get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, + ) -> Result< + ( + ConsensusValidationResult, + FeeResult, + ), + Error, + > { + let TokenPlaceAskTransitionV0 { + base, + amount, + price, + } = value; + + let mut drive_operations = vec![]; + + let base_action_validation_result = + TokenBaseTransitionAction::try_from_borrowed_base_transition_with_contract_lookup( + drive, + owner_id, + base, + approximate_without_state_for_costs, + transaction, + &mut drive_operations, + get_data_contract, + platform_version, + )?; + + let mut fee_result = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + + let bump = |errors: Vec, fee_result: FeeResult| { + Ok(bump_with_errors( + base, + owner_id, + user_fee_increase, + errors, + fee_result, + )) + }; + + let (base_action, _change_note) = match base_action_validation_result.is_valid() { + true => base_action_validation_result.into_data()?, + false => return bump(base_action_validation_result.errors, fee_result), + }; + + if let Err(error) = base_action.token_configuration_or_position_error() { + return bump(vec![error], fee_result); + } + // The listing is priced at the token's decimals as the token has them now, for its life + let decimals = base_action.token_configuration()?.conventions().decimals(); + + let (order_id, order_id_fee) = derive_order_id( + drive, + TokenOrderSide::Ask, + base.token_id(), + owner_id, + base.identity_contract_nonce(), + block_info, + platform_version, + )?; + fee_result.checked_add_assign(order_id_fee)?; + let order_document = TokenOrder { + id: order_id, + side: TokenOrderSide::Ask, + maker_id: owner_id, + token_id: base.token_id(), + price: *price, + amount: *amount, + remaining: *amount, + escrow: 0, + decimals, + } + .new_document(block_info)?; + + Ok(( + BatchedTransitionAction::TokenAction(TokenTransitionAction::PlaceAskAction( + TokenPlaceAskTransitionActionV0 { + base: base_action, + order_id, + amount: *amount, + price: *price, + decimals, + order_document, + token_market_contract: drive + .token_market_contract_fetch_info(platform_version)?, + } + .into(), + )) + .into(), + fee_result, + )) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs index d0de2956eb0..a090d0135c2 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/token_transition/token_transition_action_type.rs @@ -36,6 +36,9 @@ impl TokenTransitionActionTypeGetter for TokenTransitionAction { TokenTransitionAction::DirectPurchaseToPoolAction(_) => { TokenTransitionActionType::DirectPurchaseToPool } + TokenTransitionAction::PlaceAskAction(_) => TokenTransitionActionType::PlaceAsk, + TokenTransitionAction::FillAskAction(_) => TokenTransitionActionType::FillAsk, + TokenTransitionAction::CancelOrderAction(_) => TokenTransitionActionType::CancelOrder, } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs index c1ef864e055..40f98d511ce 100644 --- a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs @@ -24,6 +24,7 @@ use crate::state_transition_action::batch::batched_transition::document_transiti use crate::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_transition_action::TokenDirectPurchaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_to_pool_transition_action::TokenDirectPurchaseToPoolTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::token_transition::token_fill_ask_transition_action::TokenFillAskTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; /// action v0 @@ -242,6 +243,13 @@ impl BatchTransitionActionV0 { // actions, so the fold sees the same sequence of prices there. TokenTransitionAction::DirectPurchaseToPoolAction(token_purchase), ) => Some(token_purchase.total_agreed_price()), + BatchedTransitionAction::TokenAction( + // The credits a buyer pays the seller of a listing. Like the + // purchase-into-pool kind above, a fill is refused unpaid at the batch's + // `is_allowed` gate before protocol version 15, so earlier versions see the + // same sequence of prices. + TokenTransitionAction::FillAskAction(fill_ask), + ) => Some(fill_ask.credits()), _ => None, }) .fold((None, false), |(acc, _), price| match acc { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index da87c27a5ca..877da8c6243 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -551,7 +551,8 @@ impl DriveOperation<'_> { } /// Refuses a batch whose token operations write one identity's balance of a token, one - /// token's total supply, or the total one token's shielded pool holds, more than once. + /// token's total supply, the total one token's shielded pool holds, or what one marketplace + /// listing holds in escrow, more than once. /// /// These writes compute the new value from the one committed before the batch too, but a /// transfer writes two balances, a mint or a burn a balance and the supply, and a shield @@ -583,12 +584,14 @@ enum BalanceKey { } /// A key a token operation writes: an identity's balance of a token, a token's total supply, -/// or the total a token's shielded pool holds +/// the total a token's shielded pool holds, or what a token marketplace listing holds in escrow #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum TokenBalanceKey { Holder(Identifier, Identifier), Supply(Identifier), PoolTotal(Identifier), + /// What one listing of the token marketplace holds in escrow (token, order) + OrderEscrow(Identifier, Identifier), } /// The keys `operation` writes if it is a token operation that moves tokens @@ -654,6 +657,27 @@ fn token_balance_writes(operation: &DriveOperation) -> Vec { TokenBalanceKey::Supply(*token_id), TokenBalanceKey::PoolTotal(*token_id), ], + // Listing moves tokens from the seller's balance into the listing's escrow, and a fill or + // a cancel moves them out of it into a balance. Neither moves the total supply: the + // escrow is a term of the conservation sum beside the balances. + TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id, + order_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *holder_id), + TokenBalanceKey::OrderEscrow(*token_id, *order_id), + ], + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id, + order_id, + recipient_id, + .. + } => vec![ + TokenBalanceKey::Holder(*token_id, *recipient_id), + TokenBalanceKey::OrderEscrow(*token_id, *order_id), + ], // A transfer inside the pool moves nothing in or out of it, so it leaves the pool // total where it stands and only records nullifiers and appends notes. TokenOperationType::TokenShieldedTransfer { .. } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs index 99ff837d42f..9a60e1b096a 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/token.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/token.rs @@ -190,6 +190,35 @@ pub enum TokenOperationType { /// The change notes of the bundle notes: Vec, }, + /// Moves `amount` of a token from `holder_id`'s balance into the escrow of the new listing + /// `order_id` (the token marketplace, protocol version 15). The supply is unchanged: the + /// escrow is a term of the token conservation sum beside the balances. + TokenPlaceInOrderEscrow { + /// The token id + token_id: Identifier, + /// The seller, whose balance the listed tokens leave + holder_id: Identifier, + /// The listing's order id + order_id: Identifier, + /// The amount listed + amount: TokenAmount, + }, + /// Moves `amount` of a token out of the escrow of the listing `order_id` into `recipient_id`'s + /// balance: the buyer of a fill, or the seller when the listing is cancelled (the token + /// marketplace, protocol version 15). `remaining_after` is what the escrow holds afterwards; + /// at 0 its entry is removed. + TokenReleaseFromOrderEscrow { + /// The token id + token_id: Identifier, + /// The listing's order id + order_id: Identifier, + /// Who receives the tokens + recipient_id: Identifier, + /// The amount leaving escrow + amount: TokenAmount, + /// What the escrow holds after the release + remaining_after: TokenAmount, + }, } impl DriveLowLevelOperationConverter for TokenOperationType { @@ -202,6 +231,68 @@ impl DriveLowLevelOperationConverter for TokenOperationType { platform_version: &PlatformVersion, ) -> Result, Error> { match self { + TokenOperationType::TokenPlaceInOrderEscrow { + token_id, + holder_id, + order_id, + amount, + } => { + let mut batch_operations = drive.remove_from_identity_token_balance_operations( + token_id.to_buffer(), + holder_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + // A transition places one listing, and each transition's operations apply as a + // batch of their own, so no earlier operation of this batch queues the token's + // escrow tree. The balance estimation above registers its layers first, and the + // escrow's deeper Tokens estimate is kept. + batch_operations.extend(drive.add_to_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + &mut None, + platform_version, + )?); + Ok(batch_operations) + } + TokenOperationType::TokenReleaseFromOrderEscrow { + token_id, + order_id, + recipient_id, + amount, + remaining_after, + } => { + let mut batch_operations = drive.add_to_identity_token_balance_operations( + token_id.to_buffer(), + recipient_id.to_buffer(), + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + if remaining_after > 0 { + batch_operations.extend(drive.set_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + remaining_after, + estimated_costs_only_with_layer_info, + platform_version, + )?); + } else { + batch_operations.extend(drive.remove_token_order_escrow_operations( + token_id.to_buffer(), + order_id.to_buffer(), + estimated_costs_only_with_layer_info, + platform_version, + )?); + } + Ok(batch_operations) + } TokenOperationType::TokenBurn { token_id, identity_balance_holder_id, diff --git a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs index 71fdc3d7cf0..c5b839a541e 100644 --- a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs +++ b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs @@ -18,6 +18,7 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio }; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_burn_from_pool_transition::v0::v0_methods::TokenBurnFromPoolTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; use dpp::state_transition::batch_transition::token_freeze_transition::v0::v0_methods::TokenFreezeTransitionV0Methods; use dpp::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; use dpp::state_transition::batch_transition::token_shielded_transfer_transition::v0::v0_methods::TokenShieldedTransferTransitionV0Methods; @@ -25,6 +26,7 @@ use dpp::state_transition::batch_transition::token_transfer_transition::v0::v0_m use dpp::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; use dpp::state_transition::batch_transition::token_unshield_transition::v0::v0_methods::TokenUnshieldTransitionV0Methods; use dpp::system_data_contracts::load_system_data_contract; +use dpp::tokens::token_order_side::TokenOrderSide; use grovedb::PathQuery; use platform_version::version::PlatformVersion; @@ -50,6 +52,24 @@ fn create_token_historical_document_query( query.construct_path_query(platform_version) } +/// The path query of one token market contract order document. +fn create_token_order_document_query( + side: TokenOrderSide, + order_id: Identifier, + platform_version: &PlatformVersion, +) -> Result { + let query = SingleDocumentDriveQuery { + contract_id: SystemDataContract::TokenMarket.id().into_buffer(), + document_type_name: side.document_type_name().to_string(), + document_type_keeps_history: false, + document_id: order_id.to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + }; + + query.construct_path_query(platform_version) +} + fn create_token_group_action_query( contract_id: [u8; 32], identity_id: [u8; 32], @@ -200,6 +220,29 @@ impl TryTransitionIntoPathQuery for TokenTransition { .collect(); token_shielded_pool_nullifiers_path_query(token_id.to_buffer(), &nullifiers) } + // The token marketplace keeps no token history: a listing is attested by its `ask` + // document, a cancel by the order document no longer existing, and a fill by the + // buyer's balance. + TokenTransition::PlaceAsk(_) => create_token_order_document_query( + TokenOrderSide::Ask, + TokenOrderSide::Ask.order_id( + token_id, + owner_id, + self.base().identity_contract_nonce(), + ), + platform_version, + )?, + TokenTransition::CancelOrder(token_cancel_order_transition) => { + create_token_order_document_query( + token_cancel_order_transition.side(), + token_cancel_order_transition.order_id(), + platform_version, + )? + } + TokenTransition::FillAsk(_) => Drive::token_balance_for_identity_id_query( + token_id.to_buffer(), + owner_id.to_buffer(), + ), }; if let Some(group_state_transition_info) = self.base().using_group_info() { diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index d7d57d828db..080a839cec9 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -83,6 +83,10 @@ use platform_version::version::PlatformVersion; use crate::drive::Drive; use crate::drive::identity::key::fetch::IdentityKeysRequest; use crate::verify::RootHash; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use dpp::tokens::token_order::TokenOrder; +use dpp::tokens::token_order_side::TokenOrderSide; use crate::error::Error; use crate::error::proof::ProofError; use crate::query::{ContractLookupFn, SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; @@ -608,6 +612,23 @@ impl Drive { let data_contract_id = token_transition.data_contract_id(); let token_id = token_transition.token_id(); + // A marketplace transition's proof is read by its token id, so the id + // must be the one its contract and position derive, as the node's + // structure validation requires: a request naming one token's position + // with another's id never executes, and must not borrow the evidence of + // the one that did. + if token_transition.is_marketplace_order() + && token_id != token_transition.base().calculate_token_id() + { + return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( + "the token marketplace transition names token {} at position {} of contract {}, which derive token {}", + token_id, + token_transition.base().token_contract_position(), + data_contract_id, + token_transition.base().calculate_token_id() + )))); + } + let contract = known_contracts_provider_fn(&data_contract_id)?.ok_or( Error::Proof(ProofError::UnknownContract(format!( "unknown contract with id {} in token verification", @@ -1319,6 +1340,101 @@ impl Drive { Ok((root_hash, VerifiedShieldedNullifiers(statuses))) } } + // A listing is proven by its `ask` document. Its order id follows from + // the token, the signer and the nonce, so only this transition creates + // a document with it: one holding the transition's terms proves the + // placement even after fills, which leave `remaining` lower (from the + // amount down to 1) and change nothing else. A listing filled entirely + // is gone, and its document no longer proves the placement. + TokenTransition::PlaceAsk(token_place_ask_transition) => { + // A placement is never a group action: the node refuses group + // information on it, so a transition carrying some was not + // executed, and its proof must not borrow a group action's + // evidence (which may stand without the placement's record). + if token_transition.base().using_group_info().is_some() { + return Err(Error::Proof(ProofError::UnexpectedResultProof( + "a token listing placement carries group action information, which no executed placement has (token place ask)".to_string(), + ))); + } + let order_id = TokenOrderSide::Ask.order_id( + token_id, + owner_id, + identity_contract_nonce, + ); + let (root_hash, document) = verify_token_order_document( + proof, + TokenOrderSide::Ask, + order_id, + carries_owner_balance, + platform_version, + )?; + let Some(document) = document else { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "proof did not contain the listing {} expected to exist because of state transition (token place ask)", + order_id + )))); + }; + let order = + TokenOrder::from_document(&document, TokenOrderSide::Ask)?; + // The decimals are the token's when the listing was placed, which + // the platform recorded and the proof holds; the transition does + // not name them. + if order.token_id != token_id + || order.maker_id != owner_id + || order.price != token_place_ask_transition.price() + || order.amount != token_place_ask_transition.amount() + // Fills lower what is open, never to 0 (a listing filled + // entirely is deleted) nor above what was listed. + || order.remaining == 0 + || order.remaining > order.amount + { + return Err(Error::Proof(ProofError::UnexpectedResultProof(format!( + "proof of the listing {} does not hold the terms of state transition (token place ask)", + order_id + )))); + } + Ok(( + root_hash, + VerifiedDocuments(BTreeMap::from([(order_id, Some(document))])), + )) + } + // A cancel is proven by the order no longer existing. + TokenTransition::CancelOrder(token_cancel_order_transition) => { + let order_id = token_cancel_order_transition.order_id(); + let (root_hash, document) = verify_token_order_document( + proof, + token_cancel_order_transition.side(), + order_id, + carries_owner_balance, + platform_version, + )?; + if document.is_some() { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "proof contained the order {} expected to be gone because of state transition (token cancel order)", + order_id + )))); + } + Ok(( + root_hash, + VerifiedDocuments(BTreeMap::from([(order_id, None)])), + )) + } + // A fill is proven by the buyer's balance. + TokenTransition::FillAsk(_) => { + let (root_hash, Some(balance)) = + Drive::verify_token_balance_for_identity_id( + proof, + token_id.into_buffer(), + owner_id.into_buffer(), + carries_owner_balance, + platform_version, + )? + else { + return Err(Error::Proof(ProofError::IncorrectProof( + format!("proof did not contain token balance for identity {} expected to exist because of state transition (token fill ask)", owner_id)))); + }; + Ok((root_hash, VerifiedTokenBalance(owner_id, balance))) + } } } } @@ -3208,6 +3324,12 @@ impl Drive { // not the amount burned: two burns of the same notes leaving different // change spend the same nullifiers. TokenTransition::BurnFromPool(_) => grouped, + // A listing's document carries its id, derived from the signer and the + // nonce, and the transition's terms, which the verification compares. + TokenTransition::PlaceAsk(_) => true, + // A fill leaves a balance snapshot and a cancel an absence, which the + // order being filled entirely would leave too. + TokenTransition::FillAsk(_) | TokenTransition::CancelOrder(_) => false, } } }, @@ -3336,6 +3458,36 @@ impl Drive { } } +/// Verifies the proof of one token market contract order document: the document when the order +/// is open, `None` when it is not. +fn verify_token_order_document( + proof: &[u8], + side: TokenOrderSide, + order_id: Identifier, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, +) -> Result<(RootHash, Option), Error> { + let token_market_contract = + load_system_data_contract(SystemDataContract::TokenMarket, platform_version)?; + let document_type = token_market_contract + .document_type_for_name(side.document_type_name()) + .map_err(|e| Error::Proof(ProofError::UnknownContract(e.to_string())))?; + let query = SingleDocumentDriveQuery { + contract_id: token_market_contract.id().into_buffer(), + document_type_name: side.document_type_name().to_string(), + document_type_keeps_history: false, + document_id: order_id.to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + }; + query.verify_proof( + verify_subset_of_proof, + proof, + document_type, + platform_version, + ) +} + /// A moderator's document deletion is proved by the record it left: the one of the document /// named, saying that the transition's signer removed it for the transition's reason, and not /// marked restored. When is the block's to say, and whose the document was only the record diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 3f2a0e083c2..37305bfaa46 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -317,6 +317,14 @@ pub struct DriveAbciDocumentsStateTransitionValidationVersions { pub token_unshield_transition_state_validation: FeatureVersion, pub token_shielded_transfer_transition_structure_validation: FeatureVersion, pub token_shielded_transfer_transition_state_validation: FeatureVersion, + /// The token marketplace transitions (protocol version 15), refused before their + /// activation by the `is_allowed` gate, so these are 0 in every table. + pub token_place_ask_transition_structure_validation: FeatureVersion, + pub token_place_ask_transition_state_validation: FeatureVersion, + pub token_fill_ask_transition_structure_validation: FeatureVersion, + pub token_fill_ask_transition_state_validation: FeatureVersion, + pub token_cancel_order_transition_structure_validation: FeatureVersion, + pub token_cancel_order_transition_state_validation: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 48d526658a3..732625d99b9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index ae45ad2be05..bc6f5e67cfe 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -307,6 +307,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 8b05b02e8aa..02dacf1ae4e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 6fa1e0b8ff3..1f9b547400c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -206,6 +206,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 18de98b3f8b..41e0b780017 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -209,6 +209,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index b51550463b0..9e8cfdfeff5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -210,6 +210,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index baf9dea15d5..0b15a204416 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -213,6 +213,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index 96ea9ba6999..c0541896bbb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -207,6 +207,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 30c6b50ea64..665f8dbc24a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -261,6 +261,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index 39725f600a3..f5cba4b5368 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -257,6 +257,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = token_unshield_transition_state_validation: 0, token_shielded_transfer_transition_structure_validation: 0, token_shielded_transfer_transition_state_validation: 0, + token_place_ask_transition_structure_validation: 0, + token_place_ask_transition_state_validation: 0, + token_fill_ask_transition_structure_validation: 0, + token_fill_ask_transition_state_validation: 0, + token_cancel_order_transition_structure_validation: 0, + token_cancel_order_transition_state_validation: 0, }, identity_create_from_addresses_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 24d185ca160..1cedf0b5b82 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -70,6 +70,9 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub token_burn_from_pool_transition: FeatureVersion, pub token_claim_to_pool_transition: FeatureVersion, pub token_direct_purchase_to_pool_transition: FeatureVersion, + pub token_place_ask_transition: FeatureVersion, + pub token_fill_ask_transition: FeatureVersion, + pub token_cancel_order_transition: FeatureVersion, pub identity_credit_transfer_to_addresses_transition: FeatureVersion, pub address_funds_transfer_transition: FeatureVersion, pub address_credit_withdrawal_transition: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index 0a0bab12866..780ecadb558 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -57,6 +57,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index 5af37c28480..7a0f849b204 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -58,6 +58,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 8dbd1d13b93..e15b746f2e3 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -62,6 +62,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index 78aa8efd67d..02f2e06755e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -62,6 +62,9 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV token_burn_from_pool_transition: 0, token_claim_to_pool_transition: 0, token_direct_purchase_to_pool_transition: 0, + token_place_ask_transition: 0, + token_fill_ask_transition: 0, + token_cancel_order_transition: 0, identity_credit_transfer_to_addresses_transition: 0, address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 154e507895b..a98d69deb93 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -193,6 +193,12 @@ pub struct SystemLimits { /// then start refusing batches that fee validation would have executed. Recheck the /// inequality rather than assuming it. /// + /// * A token marketplace fill (protocol version 15) reads its order from state and writes + /// an absolute `remaining` and escrow. Two fills of one order in one batch would both read + /// the order as the batch found it, release more than its escrow held, and write a stale + /// `remaining`. Raising the cap needs fills transformed against a batch-local order state + /// (or a refusal of an order filled or cancelled twice in a batch). + /// /// Five cases in `rs-drive`'s `batched_group_drain` suite are `#[ignore]`d for exactly this /// reason; the rest of that suite runs. Anyone raising this cap should un-ignore those five /// first and make them pass. diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 255733a8cc3..45f14b882de 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -130,7 +130,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// /// 5. **Tradeable tokens**: `TokenTradeMode::Tradeable` /// (`TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION`) lets a token's holders list it and make -/// offers for it. Before this version a contract whose token is tradeable, and a +/// offers for it, with the token's minimum fill (`min_fill`, from 1 to `i64::MAX`, else +/// `InvalidTokenOrderFillRangeError` 10463 unpaid on contract create and update, and charged +/// on a `TokenConfigUpdate`). Before this version a contract whose token is tradeable, and a /// `TokenConfigUpdate` that makes a token tradeable, are refused unpaid as not active /// (`StateTransitionNotActiveError`, 10603), the refusal older software gives by failing to /// decode the mode. A tradeable token must be transferable (`NonTransferableTokenTradeableError`, @@ -547,6 +549,30 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// changes. Private document types and their store exist only from this version, so the /// first two reach no earlier state; a batch repeating a keyless append arises nowhere in /// Platform (notes are unique, and note 25 refuses identical entries). +/// +/// 27. **Token listings**: three token transitions of a batch, `TokenPlaceAsk` (lists an amount +/// of a tradeable token at a price in credits per whole token and moves it into escrow), +/// `TokenFillAsk` (buys part or all of a listing, paying the seller the change in the +/// listing's rounded-up running total) and `TokenCancelOrder` (returns what an order holds +/// to its owner). The batch's `is_allowed` gate refuses each, unpaid, before this version, +/// as software that cannot decode them refuses them. The checks that read nothing beyond the +/// contract (a tradeable token, an amount of at least its minimum fill, what the listing is +/// worth at its decimals: at most `i64::MAX` credits, `InvalidTokenOrderValueError` 10465) run +/// with the batch `advanced_structure` 2, so check_tx refuses them too; the others with state +/// validation. A listing's amount and price (1 to `i64::MAX`) are checked with the +/// transition's structure, unpaid. A listing names no decimals and no minimum fill: it records +/// the token's decimals as the token has them when it is placed (the token market contract's +/// `generatedFrom` `sys.tokens.decimals`, note 15), and its fills are priced at them; the +/// token's minimum fill (`TokenTradeMode::Tradeable`'s `min_fill`, from 1 to `i64::MAX`) is +/// read by every fill as the token has it then, and a listing of less is refused +/// (`InvalidTokenOrderFillRangeError` 10463). A listing of a token its contract does not +/// define bumps the nonce with the token base +/// rules' error. An order of a token with 39 decimals or more (decimals are a `u8`, and a +/// `Conventions` update is not held to the 16 a contract is) has a whole token of more base +/// units than any amount times any price: it is worth one credit rounded up, so a listing's +/// first fill pays 1 and the rest 0, and nothing rounded down. Their structure and state +/// validations and their high-level operation converters are 0 in every table, which only this +/// version reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index be0e36d27c8..90aab9ab612 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -34,6 +34,9 @@ use dpp::state_transition::batch_transition::document_base_transition::v2::v2_me use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; use dpp::state_transition::batch_transition::token_burn_transition::v0::v0_methods::TokenBurnTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; use dpp::state_transition::batch_transition::token_claim_transition::v0::v0_methods::TokenClaimTransitionV0Methods; use dpp::state_transition::batch_transition::token_config_update_transition::v0::v0_methods::TokenConfigUpdateTransitionV0Methods; use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::v0_methods::TokenDestroyFrozenFundsTransitionV0Methods; @@ -153,14 +156,15 @@ pub struct BatchedTransitionSummary { pub action: String, pub target: BatchedTransitionTarget, /// Credits or tokens moved: a document purchase price or update-price - /// value, a token transfer / mint / burn amount, or a direct purchase's - /// total agreed price. + /// value, a token transfer / mint / burn amount, a direct purchase's + /// total agreed price, the tokens a listing places in escrow, or the most + /// credits a listing fill spends. pub amount: Option, /// The identity on the other side, read against `action`: a document /// transfer's new owner, a token transfer's recipient, a mint's issued-to /// identity, or the frozen identity of a freeze / unfreeze / destroy. pub recipient_id: Option, - /// Tokens bought by a direct purchase. + /// Tokens bought by a direct purchase, or the most a listing fill buys. pub token_count: Option, /// The material fields `amount` / `recipient_id` / `token_count` do not /// cover, one per line (document data, a config change item, a price @@ -594,6 +598,22 @@ fn summarize_token_transition( } (None, None, None, t.public_note()) } + // A listing moves `amount` tokens into escrow; its price, per whole token at the token's + // decimals when it is placed, is a detail. + TokenTransition::PlaceAsk(t) => { + lines.push(budget.write(format_args!("price: {} credits per whole token", t.price()))?); + (Some(t.amount()), None, None, None) + } + // A fill spends at most `max_total_price` credits for at most `max_amount` tokens. + TokenTransition::FillAsk(t) => { + lines.push(budget.write(format_args!("listing: {}", t.order_id()))?); + lines.push(budget.write(format_args!("minimum amount: {}", t.min_amount()))?); + (Some(t.max_total_price()), None, Some(t.max_amount()), None) + } + TokenTransition::CancelOrder(t) => { + lines.push(budget.write(format_args!("order: {} ({})", t.order_id(), t.side()))?); + (None, None, None, None) + } // Shielded-pool operations carry notes, proofs and amounts with no typed projection: the // whole transition is rendered and the row is incomplete. TokenTransition::Shield(_) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 85e35484aa2..0a49f22d3b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -897,8 +897,7 @@ public struct DataContractParser { // Parse marketplace rules if let marketplaceRules = tokenDict["marketplaceRules"] as? [String: Any] { - if let tradeModeStr = marketplaceRules["tradeMode"] as? String, - let tradeMode = TokenTradeMode(rawValue: tradeModeStr) { + if let tradeMode = parseTokenTradeMode(marketplaceRules["tradeMode"]) { token.tradeMode = tradeMode } token.tradeModeChangeRules = parseChangeControlRule(marketplaceRules["tradeModeChangeRules"]) @@ -914,6 +913,20 @@ public struct DataContractParser { } } + /// 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; `minFill` is not kept. + private static func parseTokenTradeMode(_ value: Any?) -> TokenTradeMode? { + if let name = value as? String { + return TokenTradeMode(rawValue: name) + } + if let tagged = value as? [String: Any], tagged.count == 1, let name = tagged.keys.first { + return TokenTradeMode(rawValue: name) + } + return nil + } + private static func parseChangeControlRule(_ ruleData: Any?) -> ChangeControlRules? { guard let ruleContainer = ruleData as? [String: Any] else { return nil } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index cd22aefcdfe..311047ea78e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -241,8 +241,14 @@ public enum TokenDistributionRecipient: String, CaseIterable, Codable, Sendable // MARK: - Token Trade Mode -/// Trading modes for tokens. Raw values match rs-dpp's `TokenTradeMode` in -/// contract JSON. `tradeable` is valid from protocol version 15. +/// Trading modes for tokens. Raw values are the variant names of rs-dpp's +/// `TokenTradeMode`. `tradeable` is valid from protocol version 15. +/// +/// Contract JSON carries `NotTradeable` as a bare string and `Tradeable` as +/// an object holding its `minFill`, `{"Tradeable": {"minFill": 1000}}`: the +/// smallest amount, in base units, a fill of one of the token's listings or +/// offers takes unless it takes all the order has left. Only the mode is +/// kept here; `minFill` is not persisted. /// /// Stored on `PersistentToken` as its raw string, so a new case leaves the /// entity hash unchanged. The frozen schema copies keep only `notTradeable`. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift index cfa9bc730e8..63b2297cda1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTradeModeTests.swift @@ -5,20 +5,22 @@ import SwiftData /// Coverage for `DataContractParser`'s token trade mode parsing. /// /// rs-dpp serialises `TokenMarketplaceRules` flat, tagged with its format -/// version, and `TokenTradeMode` as a bare string: +/// version, and `TokenTradeMode` externally tagged: `NotTradeable` as a bare +/// string, `Tradeable` as an object holding its `minFill` (a JSON number, or a +/// decimal string above 2^53 - 1): /// /// ```json /// "marketplaceRules": { /// "$formatVersion": "0", -/// "tradeMode": "Tradeable", +/// "tradeMode": { "Tradeable": { "minFill": 1000 } }, /// "tradeModeChangeRules": { "$formatVersion": "0", ... } /// } /// ``` /// -/// "Tradeable" is valid from protocol version 15. The Swift enum used to -/// have only `notTradeable`, so the parser's `TokenTradeMode(rawValue:)` -/// returned nil for it and the token kept the `notTradeable` default, which -/// made `PersistentToken.isTradeable` report false for a tradeable token. +/// "Tradeable" is valid from protocol version 15. A parser reading only a +/// string `tradeMode` misses the object, so the token keeps the +/// `notTradeable` default and `PersistentToken.isTradeable` reports false +/// for a tradeable token. `minFill` itself is not persisted. /// /// These tests drive the public `parseDataContract` entry point against an /// in-memory `ModelContainer` (no network, no FFI handle), save, and read the @@ -75,7 +77,7 @@ final class DataContractParserTradeModeTests: XCTestCase { /// A token configuration whose `marketplaceRules` carries `tradeMode` /// in the real rs-dpp wire shape. - private func tokenDict(tradeMode: String) -> [String: Any] { + private func tokenDict(tradeMode: Any) -> [String: Any] { return [ "$formatVersion": "0", "baseSupply": 0, @@ -96,7 +98,8 @@ final class DataContractParserTradeModeTests: XCTestCase { } func testParsesTradeableTradeModeFromContractJSON() throws { - try withParsedToken(tokenDict(tradeMode: "Tradeable")) { token in + let tradeable: [String: Any] = ["Tradeable": ["minFill": 1000]] + try withParsedToken(tokenDict(tradeMode: tradeable)) { token in XCTAssertEqual(token.tradeMode, .tradeable) XCTAssertTrue(token.isTradeable) XCTAssertEqual(token.tradeMode.displayName, "Tradeable") @@ -108,6 +111,16 @@ final class DataContractParserTradeModeTests: XCTestCase { } } + /// Above 2^53 - 1 rs-dpp writes `minFill` as a decimal string; the mode + /// is read from the variant name alone, whatever its fields hold. + func testTradeableWithStringMinFillParsesAsTradeable() throws { + let tradeable: [String: Any] = ["Tradeable": ["minFill": "9223372036854775807"]] + try withParsedToken(tokenDict(tradeMode: tradeable)) { token in + XCTAssertEqual(token.tradeMode, .tradeable) + XCTAssertTrue(token.isTradeable) + } + } + func testNotTradeableTradeModeStaysNotTradeable() throws { try withParsedToken(tokenDict(tradeMode: "NotTradeable")) { token in XCTAssertEqual(token.tradeMode, .notTradeable) @@ -116,8 +129,9 @@ final class DataContractParserTradeModeTests: XCTestCase { } } - /// The raw values are the strings rs-dpp writes; a rename on either side - /// would silently fall back to the default again. + /// The raw values are the variant names rs-dpp writes (the bare string, + /// or the object's single key); a rename on either side would silently + /// fall back to the default again. func testTradeModeRawValuesMatchTheRustSerdeNames() { XCTAssertEqual(TokenTradeMode.notTradeable.rawValue, "NotTradeable") XCTAssertEqual(TokenTradeMode.tradeable.rawValue, "Tradeable") diff --git a/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs b/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs index 9c999f1860b..dcb9d9de7d5 100644 --- a/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs +++ b/packages/wasm-dpp/src/document/state_transition/batch_transition/token_transition/mod.rs @@ -51,6 +51,9 @@ pub enum TokenTransitionType { BurnFromPool, ClaimToPool, DirectPurchaseToPool, + PlaceAsk, + FillAsk, + CancelOrder, } impl From<&TokenTransition> for TokenTransitionType { @@ -76,6 +79,9 @@ impl From<&TokenTransition> for TokenTransitionType { TokenTransition::BurnFromPool(_) => TokenTransitionType::BurnFromPool, TokenTransition::ClaimToPool(_) => TokenTransitionType::ClaimToPool, TokenTransition::DirectPurchaseToPool(_) => TokenTransitionType::DirectPurchaseToPool, + TokenTransition::PlaceAsk(_) => TokenTransitionType::PlaceAsk, + TokenTransition::FillAsk(_) => TokenTransitionType::FillAsk, + TokenTransition::CancelOrder(_) => TokenTransitionType::CancelOrder, } } } @@ -176,6 +182,12 @@ impl TokenTransitionWasm { | TokenTransition::DirectPurchaseToPool(_) => Err(JsValue::from_str( "shielded and pool-backed token transitions are not supported in wasm-dpp TokenTransition; use wasm-dpp2", )), + // The token marketplace kinds likewise: wasm-dpp2 is the binding that exposes them. + TokenTransition::PlaceAsk(_) + | TokenTransition::FillAsk(_) + | TokenTransition::CancelOrder(_) => Err(JsValue::from_str( + "token marketplace transitions are not supported in wasm-dpp TokenTransition; use wasm-dpp2", + )), } } } diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index ac96756afa9..150bb60f502 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -6,6 +6,14 @@ use crate::errors::consensus::basic::{ InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, UnsupportedProtocolVersionErrorWasm, UnsupportedVersionErrorWasm, }; +use dpp::consensus::basic::token::{ + InvalidTokenOrderFillRangeError, InvalidTokenOrderPriceError, InvalidTokenOrderValueError, +}; +use dpp::consensus::state::token::{ + TokenNotTradeableError, TokenOrderDecimalsMismatchError, TokenOrderFillAmountError, + TokenOrderNotFoundError, TokenOrderNotOwnedError, TokenOrderPriceLimitError, + TokenOrderSelfFillError, +}; use dpp::consensus::ConsensusError as DPPConsensusError; use crate::errors::consensus::basic::identity::{ @@ -539,6 +547,27 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::TokenNotTransferableError(e) => { generic_consensus_error!(TokenNotTransferableError, e).into() } + StateError::TokenNotTradeableError(e) => { + generic_consensus_error!(TokenNotTradeableError, e).into() + } + StateError::TokenOrderNotFoundError(e) => { + generic_consensus_error!(TokenOrderNotFoundError, e).into() + } + StateError::TokenOrderFillAmountError(e) => { + generic_consensus_error!(TokenOrderFillAmountError, e).into() + } + StateError::TokenOrderPriceLimitError(e) => { + generic_consensus_error!(TokenOrderPriceLimitError, e).into() + } + StateError::TokenOrderNotOwnedError(e) => { + generic_consensus_error!(TokenOrderNotOwnedError, e).into() + } + StateError::TokenOrderSelfFillError(e) => { + generic_consensus_error!(TokenOrderSelfFillError, e).into() + } + StateError::TokenOrderDecimalsMismatchError(e) => { + generic_consensus_error!(TokenOrderDecimalsMismatchError, e).into() + } StateError::IdentityInTokenConfigurationNotFoundError(e) => { generic_consensus_error!(IdentityInTokenConfigurationNotFoundError, e).into() } @@ -1472,6 +1501,15 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DuplicatePrivateDocumentEntryError(e) => { generic_consensus_error!(DuplicatePrivateDocumentEntryError, e).into() } + BasicError::InvalidTokenOrderPriceError(e) => { + generic_consensus_error!(InvalidTokenOrderPriceError, e).into() + } + BasicError::InvalidTokenOrderFillRangeError(e) => { + generic_consensus_error!(InvalidTokenOrderFillRangeError, e).into() + } + BasicError::InvalidTokenOrderValueError(e) => { + generic_consensus_error!(InvalidTokenOrderValueError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/group/token_event.rs b/packages/wasm-dpp2/src/group/token_event.rs index 0058ef5e6f0..f93af124f12 100644 --- a/packages/wasm-dpp2/src/group/token_event.rs +++ b/packages/wasm-dpp2/src/group/token_event.rs @@ -36,7 +36,11 @@ const TS_TYPES: &str = r#" * - BurnFromPool: { $type: "burnFromPool", amount, actionsDigest, publicNote } * - ClaimToPool: { $type: "claimToPool", amount } * - DirectPurchaseToPool: { $type: "directPurchaseToPool", amount, credits } + * - OrderPlaced: { $type: "orderPlaced", orderId, side, amount, price } + * - OrderFilled: { $type: "orderFilled", orderId, side, maker, amount, credits } + * - OrderCancelled: { $type: "orderCancelled", orderId, side } * + * `side` is `"ask"` (a listing) or `"bid"` (an offer). * `amount`/`credits` are routed through json_safe_u64 — small numbers, JS * BigInt-safe stringification above 2^53. Identifier fields use base58 in * JSON, Uint8Array in toObject(). @@ -87,6 +91,9 @@ pub enum TokenEventVariant { BurnFromPool = 15, ClaimToPool = 16, DirectPurchaseToPool = 17, + OrderPlaced = 18, + OrderFilled = 19, + OrderCancelled = 20, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -131,6 +138,9 @@ impl TokenEventWasm { TokenEvent::BurnFromPool(..) => TokenEventVariant::BurnFromPool, TokenEvent::ClaimToPool(..) => TokenEventVariant::ClaimToPool, TokenEvent::DirectPurchaseToPool(..) => TokenEventVariant::DirectPurchaseToPool, + TokenEvent::OrderPlaced(..) => TokenEventVariant::OrderPlaced, + TokenEvent::OrderFilled(..) => TokenEventVariant::OrderFilled, + TokenEvent::OrderCancelled(..) => TokenEventVariant::OrderCancelled, } } } @@ -152,6 +162,7 @@ mod tests { use dpp::identifier::Identifier; use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_event::TokenEvent; + use dpp::tokens::token_order_side::TokenOrderSide; /// `TokenEventObject` is an index signature, so the per-variant field names /// a TypeScript consumer works from live only in the doc block's prose and @@ -243,6 +254,9 @@ mod tests { TokenEvent::BurnFromPool(..) => "BurnFromPool", TokenEvent::ClaimToPool(..) => "ClaimToPool", TokenEvent::DirectPurchaseToPool(..) => "DirectPurchaseToPool", + TokenEvent::OrderPlaced(..) => "OrderPlaced", + TokenEvent::OrderFilled(..) => "OrderFilled", + TokenEvent::OrderCancelled(..) => "OrderCancelled", } } @@ -276,6 +290,9 @@ mod tests { TokenEvent::BurnFromPool(1, id, None), TokenEvent::ClaimToPool(1), TokenEvent::DirectPurchaseToPool(1, 1), + TokenEvent::OrderPlaced(id, TokenOrderSide::Ask, 1, 1), + TokenEvent::OrderFilled(id, TokenOrderSide::Ask, id, 1, 1), + TokenEvent::OrderCancelled(id, TokenOrderSide::Bid), ] } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs index 500b38224f2..4d29dd17314 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transition.rs @@ -8,14 +8,17 @@ use crate::state_transitions::batch::token_transitions::direct_purchase::TokenDi use crate::state_transitions::batch::token_transitions::set_price_for_direct_purchase::TokenSetPriceForDirectPurchaseTransitionWasm; use crate::state_transitions::batch::token_transitions::token_burn::TokenBurnTransitionWasm; use crate::state_transitions::batch::token_transitions::token_burn_from_pool::TokenBurnFromPoolTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_cancel_order::TokenCancelOrderTransitionWasm; use crate::state_transitions::batch::token_transitions::token_claim::TokenClaimTransitionWasm; use crate::state_transitions::batch::token_transitions::token_claim_to_pool::TokenClaimToPoolTransitionWasm; use crate::state_transitions::batch::token_transitions::token_destroy_frozen_funds::TokenDestroyFrozenFundsTransitionWasm; use crate::state_transitions::batch::token_transitions::token_direct_purchase_to_pool::TokenDirectPurchaseToPoolTransitionWasm; use crate::state_transitions::batch::token_transitions::token_emergency_action::TokenEmergencyActionTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_fill_ask::TokenFillAskTransitionWasm; use crate::state_transitions::batch::token_transitions::token_freeze::TokenFreezeTransitionWasm; use crate::state_transitions::batch::token_transitions::token_mint::TokenMintTransitionWasm; use crate::state_transitions::batch::token_transitions::token_mint_to_pool::TokenMintToPoolTransitionWasm; +use crate::state_transitions::batch::token_transitions::token_place_ask::TokenPlaceAskTransitionWasm; use crate::state_transitions::batch::token_transitions::token_shield::TokenShieldTransitionWasm; use crate::state_transitions::batch::token_transitions::token_shielded_transfer::TokenShieldedTransferTransitionWasm; use crate::state_transitions::batch::token_transitions::token_transfer::TokenTransferTransitionWasm; @@ -27,11 +30,12 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio TokenTransition, TokenTransitionV0Methods, }; use dpp::state_transition::batch_transition::{ - TokenBurnFromPoolTransition, TokenBurnTransition, TokenClaimToPoolTransition, - TokenClaimTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, - TokenDirectPurchaseToPoolTransition, TokenDirectPurchaseTransition, - TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintToPoolTransition, - TokenMintTransition, TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, + TokenBurnFromPoolTransition, TokenBurnTransition, TokenCancelOrderTransition, + TokenClaimToPoolTransition, TokenClaimTransition, TokenConfigUpdateTransition, + TokenDestroyFrozenFundsTransition, TokenDirectPurchaseToPoolTransition, + TokenDirectPurchaseTransition, TokenEmergencyActionTransition, TokenFillAskTransition, + TokenFreezeTransition, TokenMintToPoolTransition, TokenMintTransition, TokenPlaceAskTransition, + TokenSetPriceForDirectPurchaseTransition, TokenShieldTransition, TokenShieldedTransferTransition, TokenTransferTransition, TokenUnfreezeTransition, TokenUnshieldTransition, }; @@ -40,7 +44,7 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const TOKEN_TRANSITION_TYPES_TS: &str = r#" -export type TokenTransitionLike = TokenMintTransition | TokenBurnTransition | TokenTransferTransition | TokenFreezeTransition | TokenUnFreezeTransition | TokenDestroyFrozenFundsTransition | TokenClaimTransition | TokenEmergencyActionTransition | TokenConfigUpdateTransition | TokenDirectPurchaseTransition | TokenSetPriceForDirectPurchaseTransition | TokenShieldTransition | TokenUnshieldTransition | TokenShieldedTransferTransition | TokenMintToPoolTransition | TokenBurnFromPoolTransition | TokenClaimToPoolTransition | TokenDirectPurchaseToPoolTransition; +export type TokenTransitionLike = TokenMintTransition | TokenBurnTransition | TokenTransferTransition | TokenFreezeTransition | TokenUnFreezeTransition | TokenDestroyFrozenFundsTransition | TokenClaimTransition | TokenEmergencyActionTransition | TokenConfigUpdateTransition | TokenDirectPurchaseTransition | TokenSetPriceForDirectPurchaseTransition | TokenShieldTransition | TokenUnshieldTransition | TokenShieldedTransferTransition | TokenMintToPoolTransition | TokenBurnFromPoolTransition | TokenClaimToPoolTransition | TokenDirectPurchaseToPoolTransition | TokenPlaceAskTransition | TokenFillAskTransition | TokenCancelOrderTransition; "#; /// Extern type for flexible TokenTransition input @@ -70,6 +74,9 @@ impl_from_for_extern_type!( TokenBurnFromPoolTransitionWasm, TokenClaimToPoolTransitionWasm, TokenDirectPurchaseToPoolTransitionWasm, + TokenPlaceAskTransitionWasm, + TokenFillAskTransitionWasm, + TokenCancelOrderTransitionWasm, ); #[derive(Debug, Clone, PartialEq)] @@ -170,6 +177,17 @@ impl TokenTransitionWasm { TokenDirectPurchaseToPoolTransitionWasm::try_from(&transition)?, )) } + "TokenPlaceAskTransition" => TokenTransition::from(TokenPlaceAskTransition::from( + TokenPlaceAskTransitionWasm::try_from(&transition)?, + )), + "TokenFillAskTransition" => TokenTransition::from(TokenFillAskTransition::from( + TokenFillAskTransitionWasm::try_from(&transition)?, + )), + "TokenCancelOrderTransition" => { + TokenTransition::from(TokenCancelOrderTransition::from( + TokenCancelOrderTransitionWasm::try_from(&transition)?, + )) + } _ => { return Err(WasmDppError::invalid_argument("Bad token transition input")); } @@ -235,6 +253,15 @@ impl TokenTransitionWasm { TokenTransition::DirectPurchaseToPool(token_transition) => { TokenDirectPurchaseToPoolTransitionWasm::from(token_transition).into() } + TokenTransition::PlaceAsk(token_transition) => { + TokenPlaceAskTransitionWasm::from(token_transition).into() + } + TokenTransition::FillAsk(token_transition) => { + TokenFillAskTransitionWasm::from(token_transition).into() + } + TokenTransition::CancelOrder(token_transition) => { + TokenCancelOrderTransitionWasm::from(token_transition).into() + } } } @@ -259,6 +286,9 @@ impl TokenTransitionWasm { TokenTransition::BurnFromPool(_) => 15, TokenTransition::ClaimToPool(_) => 16, TokenTransition::DirectPurchaseToPool(_) => 17, + TokenTransition::PlaceAsk(_) => 18, + TokenTransition::FillAsk(_) => 19, + TokenTransition::CancelOrder(_) => 20, } } @@ -285,6 +315,9 @@ impl TokenTransitionWasm { TokenTransition::BurnFromPool(_) => "BurnFromPool".to_string(), TokenTransition::ClaimToPool(_) => "ClaimToPool".to_string(), TokenTransition::DirectPurchaseToPool(_) => "DirectPurchaseToPool".to_string(), + TokenTransition::PlaceAsk(_) => "PlaceAsk".to_string(), + TokenTransition::FillAsk(_) => "FillAsk".to_string(), + TokenTransition::CancelOrder(_) => "CancelOrder".to_string(), } } diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs index 8127328dc8b..e66ab8495fe 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/mod.rs @@ -3,14 +3,17 @@ pub mod direct_purchase; pub mod set_price_for_direct_purchase; pub mod token_burn; pub mod token_burn_from_pool; +pub mod token_cancel_order; pub mod token_claim; pub mod token_claim_to_pool; pub mod token_destroy_frozen_funds; pub mod token_direct_purchase_to_pool; pub mod token_emergency_action; +pub mod token_fill_ask; pub mod token_freeze; pub mod token_mint; pub mod token_mint_to_pool; +pub mod token_place_ask; pub mod token_shield; pub mod token_shielded_transfer; pub mod token_transfer; diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs new file mode 100644 index 00000000000..d3f63e08211 --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_cancel_order.rs @@ -0,0 +1,130 @@ +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_with, try_to_string}; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_cancel_order_transition::v0::v0_methods::TokenCancelOrderTransitionV0Methods; +use dpp::state_transition::batch_transition::token_cancel_order_transition::TokenCancelOrderTransitionV0; +use dpp::state_transition::batch_transition::TokenCancelOrderTransition; +use dpp::tokens::token_order_side::TokenOrderSide; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_CANCEL_ORDER_OPTIONS_TS: &str = r#" +/** The side of a token marketplace order: a listing (`ask`) or an offer (`bid`). */ +export type TokenOrderSide = "ask" | "bid"; + +/** Cancels the sender's open order `orderId` and returns what it holds in escrow. */ +export interface TokenCancelOrderTransitionOptions { + base: TokenBaseTransition; + orderId: IdentifierLike; + side: TokenOrderSide; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenCancelOrderTransitionOptions")] + pub type TokenCancelOrderTransitionOptionsJs; +} + +fn order_side_from_str(side: &str) -> WasmDppResult { + match side { + "ask" => Ok(TokenOrderSide::Ask), + "bid" => Ok(TokenOrderSide::Bid), + other => Err(WasmDppError::invalid_argument(format!( + "'side' must be \"ask\" or \"bid\", got \"{}\"", + other + ))), + } +} + +fn order_side_to_str(side: TokenOrderSide) -> &'static str { + match side { + TokenOrderSide::Ask => "ask", + TokenOrderSide::Bid => "bid", + } +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenCancelOrderTransition")] +pub struct TokenCancelOrderTransitionWasm(TokenCancelOrderTransition); + +impl From for TokenCancelOrderTransition { + fn from(transition: TokenCancelOrderTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenCancelOrderTransitionWasm { + fn from(transition: TokenCancelOrderTransition) -> Self { + TokenCancelOrderTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenCancelOrderTransition)] +impl TokenCancelOrderTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenCancelOrderTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let order_id: IdentifierWasm = try_from_options(&options, "orderId")?; + + let side = try_from_options_with(&options, "side", |v| { + order_side_from_str(&try_to_string(v, "side")?) + })?; + + Ok(TokenCancelOrderTransitionWasm( + TokenCancelOrderTransition::V0(TokenCancelOrderTransitionV0 { + base: base.into(), + order_id: order_id.into(), + side, + }), + )) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = orderId)] + pub fn order_id(&self) -> IdentifierWasm { + self.0.order_id().into() + } + + #[wasm_bindgen(getter = side, unchecked_return_type = "TokenOrderSide")] + pub fn side(&self) -> String { + order_side_to_str(self.0.side()).to_string() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + #[wasm_bindgen(setter = orderId)] + pub fn set_order_id( + &mut self, + #[wasm_bindgen(js_name = "orderId")] order_id: IdentifierLikeJs, + ) -> WasmDppResult<()> { + self.0.set_order_id(order_id.try_into()?); + Ok(()) + } + + #[wasm_bindgen(setter = side)] + pub fn set_side( + &mut self, + #[wasm_bindgen(unchecked_param_type = "TokenOrderSide")] side: String, + ) -> WasmDppResult<()> { + self.0.set_side(order_side_from_str(&side)?); + Ok(()) + } +} + +impl_try_from_js_value!(TokenCancelOrderTransitionWasm, "TokenCancelOrderTransition"); +impl_wasm_type_info!(TokenCancelOrderTransitionWasm, TokenCancelOrderTransition); diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs new file mode 100644 index 00000000000..d5a8a338e55 --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_fill_ask.rs @@ -0,0 +1,154 @@ +use crate::error::WasmDppResult; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_with, try_to_u64}; +use dpp::balances::credits::TokenAmount; +use dpp::fee::Credits; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_fill_ask_transition::v0::v0_methods::TokenFillAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_fill_ask_transition::TokenFillAskTransitionV0; +use dpp::state_transition::batch_transition::TokenFillAskTransition; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_FILL_ASK_OPTIONS_TS: &str = r#" +/** + * Buys from the listing `orderId`: between `minAmount` and `maxAmount` base units, for at most + * `maxTotalPrice` credits in all. The fill takes as much as the range and the listing allow. + */ +export interface TokenFillAskTransitionOptions { + base: TokenBaseTransition; + orderId: IdentifierLike; + minAmount: bigint; + maxAmount: bigint; + maxTotalPrice: bigint; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenFillAskTransitionOptions")] + pub type TokenFillAskTransitionOptionsJs; +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenFillAskTransition")] +pub struct TokenFillAskTransitionWasm(TokenFillAskTransition); + +impl From for TokenFillAskTransition { + fn from(transition: TokenFillAskTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenFillAskTransitionWasm { + fn from(transition: TokenFillAskTransition) -> Self { + TokenFillAskTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenFillAskTransition)] +impl TokenFillAskTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenFillAskTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let order_id: IdentifierWasm = try_from_options(&options, "orderId")?; + + let min_amount: TokenAmount = + try_from_options_with(&options, "minAmount", |v| try_to_u64(v, "minAmount"))?; + + let max_amount: TokenAmount = + try_from_options_with(&options, "maxAmount", |v| try_to_u64(v, "maxAmount"))?; + + let max_total_price: Credits = try_from_options_with(&options, "maxTotalPrice", |v| { + try_to_u64(v, "maxTotalPrice") + })?; + + Ok(TokenFillAskTransitionWasm(TokenFillAskTransition::V0( + TokenFillAskTransitionV0 { + base: base.into(), + order_id: order_id.into(), + min_amount, + max_amount, + max_total_price, + }, + ))) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = orderId)] + pub fn order_id(&self) -> IdentifierWasm { + self.0.order_id().into() + } + + #[wasm_bindgen(getter = minAmount)] + pub fn min_amount(&self) -> TokenAmount { + self.0.min_amount() + } + + #[wasm_bindgen(getter = maxAmount)] + pub fn max_amount(&self) -> TokenAmount { + self.0.max_amount() + } + + #[wasm_bindgen(getter = maxTotalPrice)] + pub fn max_total_price(&self) -> Credits { + self.0.max_total_price() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + #[wasm_bindgen(setter = orderId)] + pub fn set_order_id( + &mut self, + #[wasm_bindgen(js_name = "orderId")] order_id: IdentifierLikeJs, + ) -> WasmDppResult<()> { + self.0.set_order_id(order_id.try_into()?); + Ok(()) + } + + // Each integer setter converts with `try_to_u64` and leaves the transition unchanged when the + // value is out of range, rather than letting the ABI narrow it to another valid term. + #[wasm_bindgen(setter = minAmount)] + pub fn set_min_amount( + &mut self, + #[wasm_bindgen(js_name = "minAmount")] min_amount: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0.set_min_amount(try_to_u64(min_amount, "minAmount")?); + Ok(()) + } + + #[wasm_bindgen(setter = maxAmount)] + pub fn set_max_amount( + &mut self, + #[wasm_bindgen(js_name = "maxAmount")] max_amount: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0.set_max_amount(try_to_u64(max_amount, "maxAmount")?); + Ok(()) + } + + #[wasm_bindgen(setter = maxTotalPrice)] + pub fn set_max_total_price( + &mut self, + #[wasm_bindgen(js_name = "maxTotalPrice")] max_total_price: &js_sys::BigInt, + ) -> WasmDppResult<()> { + self.0 + .set_max_total_price(try_to_u64(max_total_price, "maxTotalPrice")?); + Ok(()) + } +} + +impl_try_from_js_value!(TokenFillAskTransitionWasm, "TokenFillAskTransition"); +impl_wasm_type_info!(TokenFillAskTransitionWasm, TokenFillAskTransition); diff --git a/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs new file mode 100644 index 00000000000..008494b4abc --- /dev/null +++ b/packages/wasm-dpp2/src/state_transitions/batch/token_transitions/token_place_ask.rs @@ -0,0 +1,109 @@ +use crate::error::WasmDppResult; +use crate::impl_try_from_js_value; +use crate::impl_wasm_type_info; +use crate::state_transitions::batch::token_base_transition::TokenBaseTransitionWasm; +use crate::utils::{try_from_options, try_from_options_with, try_to_u64}; +use dpp::balances::credits::TokenAmount; +use dpp::fee::Credits; +use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_place_ask_transition::v0::v0_methods::TokenPlaceAskTransitionV0Methods; +use dpp::state_transition::batch_transition::token_place_ask_transition::TokenPlaceAskTransitionV0; +use dpp::state_transition::batch_transition::TokenPlaceAskTransition; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_PLACE_ASK_OPTIONS_TS: &str = r#" +/** + * Lists `amount` of the token for sale at `price` credits per whole token, at the token's + * decimals when the listing is placed, which it keeps. The tokens move into escrow until the + * listing is filled or cancelled. A fill takes at least the token's minimum fill, unless it + * takes everything left; `amount` must be at least that minimum. + */ +export interface TokenPlaceAskTransitionOptions { + base: TokenBaseTransition; + amount: bigint; + price: bigint; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TokenPlaceAskTransitionOptions")] + pub type TokenPlaceAskTransitionOptionsJs; +} + +#[derive(Debug, Clone, PartialEq)] +#[wasm_bindgen(js_name = "TokenPlaceAskTransition")] +pub struct TokenPlaceAskTransitionWasm(TokenPlaceAskTransition); + +impl From for TokenPlaceAskTransition { + fn from(transition: TokenPlaceAskTransitionWasm) -> Self { + transition.0 + } +} + +impl From for TokenPlaceAskTransitionWasm { + fn from(transition: TokenPlaceAskTransition) -> Self { + TokenPlaceAskTransitionWasm(transition) + } +} + +#[wasm_bindgen(js_class = TokenPlaceAskTransition)] +impl TokenPlaceAskTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: TokenPlaceAskTransitionOptionsJs, + ) -> WasmDppResult { + let base: TokenBaseTransitionWasm = try_from_options(&options, "base")?; + + let amount: TokenAmount = + try_from_options_with(&options, "amount", |v| try_to_u64(v, "amount"))?; + + let price: Credits = try_from_options_with(&options, "price", |v| try_to_u64(v, "price"))?; + + Ok(TokenPlaceAskTransitionWasm(TokenPlaceAskTransition::V0( + TokenPlaceAskTransitionV0 { + base: base.into(), + amount, + price, + }, + ))) + } + + #[wasm_bindgen(getter = base)] + pub fn base(&self) -> TokenBaseTransitionWasm { + self.0.base().clone().into() + } + + #[wasm_bindgen(getter = amount)] + pub fn amount(&self) -> TokenAmount { + self.0.amount() + } + + #[wasm_bindgen(getter = price)] + pub fn price(&self) -> Credits { + self.0.price() + } + + #[wasm_bindgen(setter = base)] + pub fn set_base(&mut self, base: TokenBaseTransitionWasm) { + self.0.set_base(base.into()) + } + + // Each integer setter converts with `try_to_u64` and leaves the transition unchanged when the + // value is out of range, rather than letting the ABI narrow it to another valid term. + #[wasm_bindgen(setter = amount)] + pub fn set_amount(&mut self, amount: &js_sys::BigInt) -> WasmDppResult<()> { + self.0.set_amount(try_to_u64(amount, "amount")?); + Ok(()) + } + + #[wasm_bindgen(setter = price)] + pub fn set_price(&mut self, price: &js_sys::BigInt) -> WasmDppResult<()> { + self.0.set_price(try_to_u64(price, "price")?); + Ok(()) + } +} + +impl_try_from_js_value!(TokenPlaceAskTransitionWasm, "TokenPlaceAskTransition"); +impl_wasm_type_info!(TokenPlaceAskTransitionWasm, TokenPlaceAskTransition); diff --git a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs index 43c6dba93bc..18eee039f68 100644 --- a/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs +++ b/packages/wasm-dpp2/src/tokens/configuration/trade_mode.rs @@ -1,4 +1,6 @@ +use crate::error::WasmDppResult; use crate::impl_wasm_type_info; +use crate::utils::try_to_u64; use dpp::data_contract::associated_token::token_marketplace_rules::v0::TokenTradeMode; use wasm_bindgen::prelude::wasm_bindgen; @@ -25,19 +27,30 @@ impl TokenTradeModeWasm { TokenTradeModeWasm(TokenTradeMode::NotTradeable) } - /// Holders may list the token and make offers for it (protocol version 15). + /// Holders may list the token and make offers for it (protocol version 15), each fill taking + /// at least `minFill` base units unless it takes everything an order has left. #[wasm_bindgen(js_name = "Tradeable")] - pub fn tradeable() -> TokenTradeModeWasm { - TokenTradeModeWasm(TokenTradeMode::Tradeable) + pub fn tradeable( + #[wasm_bindgen(js_name = "minFill")] min_fill: &js_sys::BigInt, + ) -> WasmDppResult { + Ok(TokenTradeModeWasm(TokenTradeMode::Tradeable { + min_fill: try_to_u64(min_fill, "minFill")?, + })) } #[wasm_bindgen(getter = "value")] pub fn value(&self) -> String { match self.0 { TokenTradeMode::NotTradeable => String::from("NotTradeable"), - TokenTradeMode::Tradeable => String::from("Tradeable"), + TokenTradeMode::Tradeable { .. } => String::from("Tradeable"), } } + + /// The smallest fill of the token's orders, `undefined` when it is not tradeable. + #[wasm_bindgen(getter = "minFill")] + pub fn min_fill(&self) -> Option { + self.0.min_fill() + } } impl_wasm_type_info!(TokenTradeModeWasm, TokenTradeMode); diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 2006c7cb2a5..1359391d72c 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -424,7 +424,12 @@ describe('TokenConfiguration', () => { describe('tradeMode', () => { it('should name both trade modes', () => { expect(wasm.TokenTradeMode.NotTradeable().value).to.equal('NotTradeable'); - expect(wasm.TokenTradeMode.Tradeable().value).to.equal('Tradeable'); + expect(wasm.TokenTradeMode.Tradeable(BigInt(100)).value).to.equal('Tradeable'); + }); + + it('should carry the minimum fill of a tradeable token', () => { + expect(wasm.TokenTradeMode.Tradeable(BigInt(100)).minFill).to.equal(BigInt(100)); + expect(wasm.TokenTradeMode.NotTradeable().minFill).to.equal(undefined); }); it('should keep a tradeable mode in the marketplace rules', () => { @@ -436,7 +441,7 @@ describe('TokenConfiguration', () => { isSelfChangingAdminActionTakersAllowed: false, }); const marketplaceRules = new wasm.TokenMarketplaceRules( - wasm.TokenTradeMode.Tradeable(), + wasm.TokenTradeMode.Tradeable(BigInt(1)), changeRules, ); diff --git a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts index 2751f3b4d8b..628ac1ba817 100644 --- a/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokensTransitions.spec.ts @@ -782,4 +782,68 @@ describe('TokenTransitions', () => { }); }); }); + + describe('TokenPlaceAskTransition', () => { + const listing = () => new wasm.TokenPlaceAskTransition({ + base: baseTransition, + amount: BigInt(1000), + price: BigInt(100), + }); + + it('should set the integer terms', () => { + const transition = listing(); + + transition.amount = BigInt(2000); + transition.price = BigInt(200); + + expect(transition.amount).to.equal(BigInt(2000)); + expect(transition.price).to.equal(BigInt(200)); + }); + + ['amount', 'price'].forEach((term) => { + it(`should refuse an out-of-range ${term} and keep the previous one`, () => { + const transition = listing(); + const previous = transition[term]; + + [BigInt(1) << BigInt(64), BigInt(-1)].forEach((value) => { + expect(() => { transition[term] = value; }).to.throw(); + expect(transition[term]).to.equal(previous); + }); + }); + }); + }); + + describe('TokenFillAskTransition', () => { + const fill = () => new wasm.TokenFillAskTransition({ + base: baseTransition, + orderId: ownerId, + minAmount: BigInt(1), + maxAmount: BigInt(1000), + maxTotalPrice: BigInt(100000), + }); + + it('should set the integer terms', () => { + const transition = fill(); + + transition.minAmount = BigInt(5); + transition.maxAmount = BigInt(500); + transition.maxTotalPrice = BigInt(50000); + + expect(transition.minAmount).to.equal(BigInt(5)); + expect(transition.maxAmount).to.equal(BigInt(500)); + expect(transition.maxTotalPrice).to.equal(BigInt(50000)); + }); + + ['minAmount', 'maxAmount', 'maxTotalPrice'].forEach((term) => { + it(`should refuse an out-of-range ${term} and keep the previous one`, () => { + const transition = fill(); + const previous = transition[term]; + + [BigInt(1) << BigInt(64), BigInt(-1)].forEach((value) => { + expect(() => { transition[term] = value; }).to.throw(); + expect(transition[term]).to.equal(previous); + }); + }); + }); + }); });