This post explains the Stellar DEX pricing limitation for indivisible tokens and the solution we use on the Litemint NFT marketplace.

Issuing Indivisible Tokens on Stellar

The smallest amount for an asset issued on Stellar is called a stroop and is equal to one ten-millionth or 0.0000001. Therefore, we can issue 1 stroop of an asset to ensure that it is indivisible (e.g. as part of the requirements for NFTs).

Stellar DEX Price Limit

However, such small amounts may cause issues on the Stellar DEX because Stellar Core internally uses INT32 (see Stellar-ledger-entries.x) for the fractional representation of prices and the INT32 type can only store values between -2,147,483,648 to 2,147,483,647 (inclusive).

The maximum price for an offer on the SDEX is 2,147,483,647, so when selling 1 stroop, the offer price goes out of bounds if the quote asset amount exceeds ~214.7 (2,147,483,647 × 0.0000001 ≈ 214.7).

For example, if you are trading NFT/USD, the maximum amount of USD you can get is 214.7 (i.e. selling at a price of 214.7 / 0.0000001), if you are trading NFT/XLM, the maximum amount of XLM will be 214.7 XLM and so on.

Therefore, this limitation may be an issue for many trading pairs.

Path Payments

Stellar provides a powerful feature called Path Payment. Path payments are used for cross-currency payments through SDEX offers. Since the source and destination of path payments can be the same, it is an extremely versatile tool (although some have abused the feature on the SDEX, leading to the Surge Pricing implementation).

The Litemint NFT marketplace leverages path payments to circumvent this limitation and allows sellers and buyers to choose their payment currency independently.

Solution

By self-issuing a synthetic asset, we fully control the ask price for an NFT. The synthetic asset acts as an intermediary — the NFT is priced against the synthetic, and the synthetic is priced against the quote asset.

SDEX NFT Pricing Diagram

Synthetic asset method for NFT pricing on Stellar

We issue 2 units of the synthetic asset for spam protection. If someone attempts to partially fill the offer, they cannot affect the NFT liquidity — and with generous spammers, we could even receive our ask price without selling the NFT.

From the seller and buyer viewpoint, the setup is fully transparent. The seller can choose any price without worrying about SDEX limitations.

Example Implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import {
    TransactionBuilder,
    Networks,
    Asset, Operation,
    BASE_FEE } from '@stellar/stellar-sdk';

const MAX_INT32 = 2 ** 31 - 1; // Hardcode result in prod.
const MAX_PRICE = Math.floor(MAX_INT32 * 0.0000001);

function bid({ account, selling, buying, price }) {
    const builder = new TransactionBuilder(account,
        { fee: BASE_FEE, networkPassphrase: Networks.PUBLIC });
    if (price < MAX_PRICE) {
        builder.addOperation(Operation.manageBuyOffer({
            selling,
            buying,
            buyAmount: '0.0000001',
            price: (price * 1e7).toFixed(7),
            offerId: 0
        }));
    } else {
        const synthetic = new Asset('SYNTH', account.id);
        builder.addOperation(Operation.manageSellOffer({
            selling: synthetic,
            buying,
            amount: '2',
            price: { n: 1, d: 1e7 },
            offerId: 0
        }));

        builder.addOperation(Operation.manageSellOffer({
            selling,
            buying: synthetic,
            amount: price.toFixed(7),
            price: { n: 1, d: price },
            offerId: 0
        }));
    }
    return builder.setTimeout(300).build();
}

function offer({ account, selling, buying, price }) {
    const builder = new TransactionBuilder(account,
        { fee: BASE_FEE, networkPassphrase: Networks.PUBLIC });
    const quantity = 0.0000001;
    if (price < MAX_PRICE) {
        builder.addOperation(Operation.manageSellOffer({
            selling,
            buying,
            amount: quantity.toFixed(7),
            price: (price * 1e7).toFixed(7),
            offerId: 0
        }));
    } else {
        const synthetic = new Asset('SYNTH', account.id);
        builder.addOperation(Operation.manageSellOffer({
            selling: synthetic,
            buying,
            amount: (Math.ceil(quantity * 1e7) * 2).toString(),
            price,
            offerId: 0
        }));

        builder.addOperation(Operation.manageSellOffer({
            selling,
            buying: synthetic,
            amount: quantity.toFixed(7),
            price: '10000000',
            offerId: 0
        }));
    }
    return builder.setTimeout(300).build();
}

The bid and offer functions creates a bid and sell offer respectively with both automatically switching to the synthetic asset method when the price exceeds the SDEX limit.

Any questions or comments, feel free to reach out.