Our first dApp on Sui

buidly
22 min readJul 22, 2024

--

Going from developing on MultiversX to developing on Sui was a challenge that was harder than expected. Even though we had experience with EVM and Solana, the Sui network posed another challenge, mainly due to its Object Model, which is very different from MultiversX, Solana or Ethereum Virtual Machine-based chains.

We had to first understand how the Sui blockchain works, and then make our way to developing smart contracts and the frontend dApp.

Our purpose in building this dApp on Sui was to explore and understand the unique capabilities of Sui, particularly its Object Model. We aimed to leverage Sui’s distinct features to create an efficient and user-friendly NFT marketplace. This project was not just about developing a functional dApp but also about pushing the boundaries of our knowledge and skills.

I. Particularities, Move, Documentation, Problems & Tools

Particularities of Sui & Comparison with Other Blockchain Networks

Compared to other blockchain networks, Sui has a novel approach, treating everything as objects that can be transferred or modified. For example, your SUI balance is not a field on your account but an Object of type Coin, which holds information like the amount and the type of token.

We can consider that each Object in Sui is essentially an NFT, though most of this complexity is hidden by dApp frontends. Therefore, Sui provides native token ownership, where an account actually owns its tokens and NFTs, unlike Ethereum Virtual Machine-based chains, where each token is tracked by a different smart contract.

Sui is also highly performant, partly because transactions are processed in parallel and some don’t need to go through consensus. Since Sui has an object-based model, there is no “global state.” Transactions involving objects owned by an account can be processed quickly in parallel without consensus. However, there are also “shared objects,” which act like a “global state” because any account can use them, which require consensus and thus have higher gas costs and slower latency.

Sui’s approach is somewhat similar to Solana, where data is stored under Accounts provided to transactions, making Sui’s objects similar to Solana Accounts, although there are some differences.

These models differ from MultiversX, where an account directly stores its data (e.g., NFTs, tokens) in its own storage, without an abstracted notion on top, such as objects.

Move

Once we got a grasp of the Sui blockchain it was time to start learning Move, which is the language used to write smart contracts on Sui.

Move is similar to Rust, since it employs a similar Ownership model, where variables/objects are “consumed” when calling other functions unless references are used. The syntax is also very similar and should feel familiar to Rust developers that have worked with other blockchain platforms like MultiversX or Solana.

The documentation contains a guide on how to start writing your first Move Package, which walks you through the basic steps involved, from writing a simple app, to writing tests and publishing a package.
There is also an overview of the Move on Sui language, since base Move doesn’t include some concepts specific to Sui.

The documentation also includes some examples of different apps that can be built on Sui.

Documentation

The Sui docs contain really helpful articles to understand the workings and concepts of the Sui Object Model and related resources.

There are also built in standards like the Coin standard, which provides a way to create Fungible Tokens that are owned by a particular account (native assets). There are also Tokens, which are like Coins but provide greater customization, for example the ability to limit transferability (spending).

Worth mentioning that the SUI token follows the same standard and is also a Coin. This can be sort of confusing, since one account can own multiple Coin objects, which can be sent independently. Moreover, our NFT Marketplace requires a specific amount of a Coin in the buy method, hence we need to do some operations with these Coin objects.

Programmable Transaction Blocks provide utilities like SplitCoins and MergeCoins that can be used in order to more easily manage Coin objects before calling functions which require a specific amount of a Coin.

Observations on the documentation

However, after starting to develop our demo dApp, we found that although the documentation explains the core concepts quite well and provides examples of different apps, it is quite unstructured, making it harder to find more advanced information.

For example, in other blockchains there is a “global state” with each contract having the ability to store information. This is not possible to do directly on Sui, and the alternative way of implementing this is not clearly explained.

To address this in our dApp, we used a shared object of a type Marketplace that can only be used by our module. This object holds information regarding our contract and needs to be passed to all endpoints that modify it. This is not to be confused with a public shared object, which can be modified by other modules as well. The difference between these two is not clearly explained, nor are there examples of when one or the other should be used.

Furthermore, since there are also Owned Objects (which can also be public or not), the differences between these in actual dApps and when they should be used are not clear at first. There is also the store ability, which is required by structs that need to be publicly transferable, e.g., accessible by other modules.

To recap and provide more clarity for developers coming from other blockchains:

  • In order to store state for a module, use a shared object (transfer::share_object).
  • If you want to allow only an address to do a specific action inside your module, use an address owned object (transfer::transfer).
  • In case you want to create a publicly transferable NFT or allow actions in multiple modules, use a public address owned object (transfer::public_transfer with store ability).
  • In case you want an object that can be modified by other modules and stored with no restrictions, use a public shared object (transfer::public_share_object with store ability).
public struct Marketplace has key {
id: UID,
listings: Table<ID, Listing>,
bids: Table<ID, vector<Bid>>,
}


fun init(ctx: &mut TxContext) {



let marketplace = Marketplace {
id: object::new(ctx),
listings: table::new<ID, Listing>(ctx),
bids: table::new<ID, vector<Bid>>(ctx),
};


event::emit(MarketplaceInit {
object_id: object::id(&marketplace),
});


transfer::share_object(marketplace);
}

Besides the global Marketplace object, which will leverage the Dynamic Fields feature of Sui by storing them inside a Table structure. This means that the objects are not actually stored inside the Marketplace object, since that will make it too big

public struct Listing has key, store {
id: UID,
price: u64,
owner: address,
nft_id: ID
}


public struct Bid has key, store {
id: UID,
nft_id: ID,
balance: Balance<SUI>,
owner: address,
}

Problems to overcome

To develop the NFT marketplace, we needed a way to store all the Listings so they could be easily accessed and deleted later. We noticed that although Sui provides some collection types, they were not sufficient for our initial needs, and we first wanted to implement custom logic.

For example, in MultiversX, more useful structures like the UnorderedSet are implemented at the Framework level. On Sui we wanted to first implemented an equivalent ourselves, but it is prone to errors. It would be useful if the Sui Move framework provided more commonly used types.

Although in the end we decided to go with a different approach, it would still help if more types could be provided by the Sui framework.

public fun place_listing<N: key + store>(marketplace: &mut Marketplace, nft: N, price: u64, ctx: &mut TxContext) {
let sender = ctx.sender();
let nft_id = object::id(&nft);
let listing = Listing {
id: object::new(ctx),
price,
owner: sender,
nft_id
};


dof::add(&mut marketplace.id, nft_id, nft);


marketplace.listings.add<ID, Listing>(nft_id, listing);
}

Using Dynamic Object Fields and Handling Argument Types

Above, we used a dynamic object field to store the NFT under the Marketplace object, allowing the NFT to still be available by its object ID in the explorer, etc. These are fields not known at compile time, with arbitrary names, in this case we store the NFT value under its ID as key. Think of it like a map, key => value pair. This should not be confused with a dynamic field, which is similar but makes the underlying object no longer accessible by its ID, acting like a wrapped object.

Initially, we used a wrapped object, but since those objects are no longer available in the explorer, we decided it was best to use a dynamic object field instead.

Another issue we encountered was regarding argument types. In the above code snippet, we have the NFT defined as a generic key + store type, allowing us to accept arbitrary NFTs defined in other modules. However, when we first tried to call this using the Sui CLI or through the frontend dApp, we got a VMVerificationOrDeserializationError.

After some trial and error, we managed to make the call through the CLI. The problem wasn’t at the contract level. In these cases, you have to specify the argument type. This can be done using the CLI in the following way:

# With normal transaction
sui client call \
--package 0x30a8e86de188c74a56495d19d0667a488289bb2a9a7c4ead7b046728ede3553e \
--module nft_marketplace \
--function place_listing \
--type-args "0x71fc364ce76f54783e10ec08543da1d896e94462e053430aa60860cf9c827017::nft_marketplace::TestnetNFT" \
--args …



# With Programmable Transaction Block
sui client ptb \
--assign marketplace @0x716587890bee5cb17114ab524b9c85ac83f44f50a888b6d7df86ba260f6c3da4 \
--assign nft @0x25bb2091de42935f4f1176e70ef815ac858e2fe37c40819f16bed81cb889cfc7 \
--assign price 200000000 \
--move-call 0x30a8e86de188c74a56495d19d0667a488289bb2a9a7c4ead7b046728ede3553e::nft_marketplace::place_listing \
"<0xe9b7fce91c894bd228f8128cf14d55dbe7c7ee6c7d94435ff7a5233307c0088d::nft_marketplace::TestnetNFT>" \

Notice the type-args argument of the normal transaction call specifying the type of the NFT we sent, and the <type> as the first argument of the PTB move-call.

In order to do this in frontend one needs to specify the typeArguments, which is detailed in the Trustless Swap example:
https://docs.sui.io/guides/developer/app-examples/trustless-swap/frontend#createacceptcancel-escrow-mutations

Unfortunately this information was only in the examples and can not be found anywhere else in the documentation to make it clearer for developers.

Differences between Table and vector

A Table collection uses Dynamic Fields to store data using Sui’s Object Model. This means that they can store arbitrarily large data which scales without affecting gas costs. For this reason, the listings inside the Marketplace object are stored using a Table, since we can have a large number of listing and we do not want to run into limitations imposed by Sui.

This means that the Table (& other dynamic fields) is not actually stored inside the Marketplace object, so the Marketplace does not get larger with each new Listing added, since that would increase gas costs and at one point the smart contract will no longer work.

For the bids we also use a Table, but because we can have multiple bids for the same listed NFT, we chose to use a vector<Bid> type stored inside the Table. What this means is that there will be a Dynamic Field that stores the vector<Bid>, which can scale up to a certain point. It could happen that this vector grows too large and we can no longer add more Bid objects to it because of gas constraints. However, for this particular example, in practice this won’t happen since we don’t expect too many bids to be created for the same particular NFT.

To recap the differences between Table and vector:

  • Use a Table (or Bag & other collection structures) when the data stored can grow arbitrarily and the size is not known beforehand; these structures store data using Dynamic Fields and the Sui Object model, so they are efficient in terms of gas costs and the object which defines these fields does not actually store them under itself
  • Use a vector when you know that the data you want to store is limited in size; this is stored inside the object where it is defined, so the larger the vector, the bigger the gas costs to access this object are going to be

Useful tools

The Sui CLI is a really useful tool that lets you interact with the blockchain and do transactions and deploy packages. Programmable Transaction Blocks are also very useful in order to do multiple operations atomically in a single transaction.

Writing integration tests was also relatively easy, since there is no self concept like in Rust and all arguments are simply passed to the functions. This means that we can easily mock the sender of a transaction since it is included in the TxContext object:

#[test]
fun test_place_listing() {
use sui::test_scenario;
use sui::test_utils::assert_eq;


let initial_owner = @0xCAFE;


let nft_id: ID;


let mut scenario = test_scenario::begin(initial_owner);
{
init(NFT_MARKETPLACE {}, scenario.ctx());


let nft = mint_to_sender(b"Name", b"Description", b"url", scenario.ctx());
nft_id = object::id(&nft);
transfer::public_transfer(nft, initial_owner);
};


scenario.next_tx(initial_owner);
{
let mut marketplace: Marketplace = scenario.take_shared<Marketplace>();
let nft = scenario.take_from_sender<TestnetNFT>();


place_listing(&mut marketplace, nft, 10, scenario.ctx());


assert_eq(marketplace.listings.length(), 1);
assert_eq(marketplace.bids.length(), 0);


test_scenario::return_shared(marketplace);
};


let effects = scenario.next_tx(initial_owner);
assert_eq(effects.num_user_events(), 1); // 1 event emitted


{
let marketplace = scenario.take_shared<Marketplace>();
let listing: &Listing = marketplace.listings.borrow<ID, Listing>(nft_id);


assert_eq(listing.owner, initial_owner);
assert_eq(listing.price, 10);


let nft: &TestnetNFT = dof::borrow<ID, TestnetNFT>(&marketplace.id, nft_id);


assert!(nft.name() == string::utf8(b"Name"), 1);
assert!(nft.description() == string::utf8(b"Description"), 1);
assert!(nft.url() == url::new_unsafe_from_bytes(b"url"), 1);
assert!(nft.creator() == initial_owner, 1);
test_scenario::return_shared(marketplace);
};


scenario.end();
}

Step-by-Step Implementation of Our NFT Marketplace on Sui

The smart contract has been deployed on testnet, with:

Package id: 0xc0143e3fd07dcea70e4891532f01e035685acd0974a83900004773e0290a0b45

Marketplace object id: 0xb32b4ded7a528bfebf56e4b75a0efe1cc091701e57146954303f733654f42cc9

II. dApp

As I was saying in the first part — developing dApps on Sui is not vastly different from other ecosystems, but the Object Model requires some adjustment. Unlike MultiversX or Solana, Sui lacks ABIs to describe the smart contracts. On MultiversX, a frontend or backend developer doesn’t need in-depth smart contract knowledge, as the ABI is sufficient to query and send transactions. On Sui, however, developers need at least a basic understanding of Move smart contracts to interact with them effectively.

To get started, I first explored the Sui dApp kit, which can be found here. Luckily, many aspects work similarly to other blockchains, with comparable hooks and wallet concepts, making the initial learning curve manageable.

To gain hands-on experience, I implemented a Distributed Counter app from the official app example docs. Additionally, the Sui JSON-RPC reference was invaluable, as the documentation only shows a few API calls, and I needed to understand all the functionalities I could leverage from the blockchain (JSON-RPC reference).

Implementing the dApp

When developing a dApp, I recommend starting from a template, as it sets up communication with the user’s wallet and the blockchain, allowing you to focus directly on your business logic.

To get started, run the following command: npm create @mysten/dapp

One minor drawback of the template is its lack of project organization. To address this, I used the standard React project structure to keep everything organized and suitable for larger projects. I appreciated that the template uses Vite, which is super fast. Additionally, I installed Tailwind for styling, although this is a personal preference.

NFT Marketplace Features

The dApp implements the following features:

  1. Displaying all the listed NFTs
  2. NFT listing
  3. Listed NFT details
  4. Buying a listed NFT
  5. Canceling a listed NFT (only by the NFT owner)
  6. Bidding on an NFT
  7. Displaying all bids
  8. Canceling a bid (only by the bid owner)
  9. Accepting a bid (only by the NFT owner)

1 Displaying all the listed NFTs

To start, we need to look at the smart contract implementation:

public struct Marketplace has key {
id: UID,
listings: Table<ID, Listing>,
bids: Table<ID, vector<Bid>>,
}

When the smart contract is deployed, it creates this Marketplace object which stores all the listings and bids. Since we have the Marketplace object ID from the deployment, we can make a JSON-RPC call to get the contents of this object. We can use the sui_getObject JSON-RPC call as detailed here.

There are two ways to call this API. We can either use an RPC hook like useSuiClientQuery from @mysten/dapp-kit, which is a wrapper around react-query, or we can use useSuiClient directly to invoke an RPC call. The first option should be used most of the time, but sometimes you need more flexibility and the second option comes in handy.

import { useSuiClientQuery } from "@mysten/dapp-kit";
import { useNetworkVariable } from "../networkConfig";

export const useGetListings = () => {
const marketplaceObjectId = useNetworkVariable("marketplaceObjectId");
const { data: marketplaceData, isPending: marketplacePending, error: marketplaceError } = useSuiClientQuery("getObject", {
id: marketplaceObjectId,
options: {
showContent: true,
showOwner: true,
},
});

const marketplaceFields =
marketplaceData?.data?.content?.dataType === "moveObject"
? (marketplaceData.data.content.fields as any)
: null;

const { data: listings, isPending: listingsPending, error: listingsError, refetch: refetchListings } = useSuiClientQuery("getDynamicFields", {
parentId: marketplaceFields?.listings?.fields?.id?.id,
}, { enabled: marketplaceFields != null });

const { data: bids, isPending: bidsPending, error: bidsError, refetch: refetchBids } = useSuiClientQuery("getDynamicFields", {
parentId: marketplaceFields?.bids?.fields?.id?.id,
}, { enabled: marketplaceFields != null });

return {
listings: listings?.data?.map(obj => obj.objectId) ?? [],
bids: bids?.data?.map(obj => obj.objectId) ?? [],
isPending: marketplacePending || listingsPending || bidsPending,
error: marketplaceError || listingsError || bidsError,
refetchListings,
refetchBids,
};
};

In the data, we will have an array with all the listings (only the object IDs). This is not enough to display the listed NFTs on the homepage.

   public struct Listing has key, store {
id: UID,
price: u64,
owner: address,
nft_id: ID
}

If we call sui_getObject, we can get the price and owner, but we still need the NFT metadata. Since we have the nft_id, then we can make another getObject RPC call to also get the NFT metadata (name, description, url, creator).

import { useSuiClientQuery } from "@mysten/dapp-kit";
import { useMemo } from "react";
import { NftWithPrice } from "../types/NftWithPrice";

export const useGetNftDetails = (objectId: string) => {
const { data: objectData, isPending: objectPending, error: objectError } = useSuiClientQuery("getObject", {
id: objectId,
options: {
showContent: true,
showOwner: true,
},
});

const objectFields =
objectData?.data?.content?.dataType === "moveObject"
? (objectData.data.content.fields as any)
: null;

const { data: nftData, isPending: nftPending, error: nftError } = useSuiClientQuery("getObject", {
id: objectFields?.value?.fields?.nft_id,
options: {
showContent: true,
showOwner: true,
},
}, { enabled: objectFields?.value?.fields != null });

const nft = useMemo(() => {
const nftFields =
nftData?.data?.content?.dataType === "moveObject"
? (nftData.data.content.fields as any)
: null;

if (!nftFields || !objectFields) {
return null;
}

return {
id: nftFields.id.id,
name: nftFields.name,
description: nftFields.description,
url: nftFields.url,
creator: nftFields.creator,
price: objectFields.value.fields.price,
owner: objectFields.value.fields.owner,
type: (nftData?.data?.content as any)?.type ?? ''
} as NftWithPrice;
}, [objectData, nftData]);


return {
nft,
isPending: objectPending || nftPending,
error: objectError || nftError
};
};

2 NFT Listing

  public fun place_listing<N: key + store>(marketplace: &mut Marketplace, nft: N, price: u64, ctx: &mut TxContext) 

Looking at the place_listing function signature, we can see that in order to list an NFT, we need three things: the marketplace object ID, the NFT, and a price. We already know the marketplace object ID, and the price will be an input from the user, so all we need to do is get the account NFTs. For this demo, I have filtered only the TestnetNFT objects.

We need to know the address of the user’s wallet, and we can use the useCurrentAccount hook. Then we can call sui_getOwnedObjects to get all the account’s owned objects and filter them to show only the TestnetNFT objects.

import { useCurrentAccount, useSuiClientQuery } from "@mysten/dapp-kit";
import { Nft } from '../types';

export const useGetAccountNfts = () => {
const account = useCurrentAccount();

const { data, isPending, error } = useSuiClientQuery(
"getOwnedObjects",
{
owner: account?.address as string, options: {
showContent: true,
showOwner: true,
},
},
{ enabled: !!account }
);

return {
nfts: data?.data?.filter(obj => {
const type = obj.data?.content?.dataType === "moveObject"
? (obj.data.content.type as any)
: null;
return type?.includes('TestnetNFT') ?? false;
})
.map(obj => {
const nft = obj.data;
const nftFields =
nft?.content?.dataType === "moveObject"
? (nft.content.fields as any)
: null;

return {
id: nft?.objectId,
name: nftFields?.name ?? '',
description: nftFields?.description ?? '',
url: nftFields?.url ?? '',
creator: nftFields?.creator ?? '',
type: (nft?.content as any)?.type ?? '',
} as Nft;
}) ?? [],
isPending,
error
};
};

Having the NFT, the price and the marketplace object id, we can build the place_listing transaction.

import {
useCurrentAccount,
useSignAndExecuteTransactionBlock,
useSuiClient,
} from "@mysten/dapp-kit";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { MIST_PER_SUI } from "@mysten/sui.js/utils";
import { toast } from "react-toastify";
import { useNetworkVariable } from "../networkConfig";

export const usePlaceListing = (onListed: (id: string) => void) => {
const account = useCurrentAccount();
const client = useSuiClient();
const marketplacePackageId = useNetworkVariable("marketplacePackageId");
const marketplaceObjectId = useNetworkVariable("marketplaceObjectId");
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

const placeListing = (objectId: string, price: number, type: string) => {
if (!account) {
return;
}

const txb = new TransactionBlock();

txb.moveCall({
arguments: [
txb.object(marketplaceObjectId),
txb.pure(objectId),
txb.pure.u64(price * Number(MIST_PER_SUI)),
],
target: `${marketplacePackageId}::nft_marketplace::place_listing`,
typeArguments: [type]
});

txb.setGasBudget(100000000);

signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
showObjectChanges: true,
},
},
{
onSuccess: (tx) => {
client
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
toast.success("NFT listed successfully.", {
autoClose: 3000,
position: "bottom-right",
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false,
draggable: false,
});

const objectId = tx.effects?.created?.[0]?.reference?.objectId;

if (objectId) {
onListed(objectId);
}
});
},
onError: (e) => {
console.log({ e });
},
},
);
};

return placeListing;
};

The important part here is the moveCall function, where we specify the package ID, module, and function we want to target (${marketplacePackageId}::nft_marketplace::place_listing), along with the arguments as shown in the Move function. One other important thing to note is that the typeArguments need to be provided in order for the transaction to succeed, since the contract allows a generic NFT object to be provided. If we don’t specify the typeArguments, we would get a VMVerificationOrDeserializationError.

3 Listed NFT details

Since we are displaying all the listed NFTs on the homepage, we also want to have a details page for each NFT, where users can buy, bid, or cancel the listing. For retrieving the NFT details, the useGetNftDetails hook described above will be reused.

4 Buy a listed NFT

public fun buy<N: key + store>(
marketplace: &mut Marketplace,
nft_id: ID,
coin: Coin<SUI>,
ctx: &mut TxContext
): N

Looking at the function signature, we need to provide the marketplace, the nft id we want to buy, and send SUI coins. Additionally, we can see that it returns the NFT, which we will then need to send to the buyer.

import {
useCurrentAccount,
useSignAndExecuteTransactionBlock,
useSuiClient,
} from "@mysten/dapp-kit";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { useNetworkVariable } from "../networkConfig";
import { toast } from "react-toastify";

export const useBuyNft = (onBuy: () => void) => {
const account = useCurrentAccount();
const client = useSuiClient();
const marketplacePackageId = useNetworkVariable("marketplacePackageId");
const marketplaceObjectId = useNetworkVariable("marketplaceObjectId");
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

const buy = (nftId: string, price: string, type: string) => {
if (!account) {
return;
}
const txb = new TransactionBlock();

const [coin] = txb.splitCoins(txb.gas, [BigInt(price)]);

const nft = txb.moveCall({
arguments: [txb.object(marketplaceObjectId), txb.pure(nftId), coin],
target: `${marketplacePackageId}::nft_marketplace::buy`,
typeArguments: [type],
});

txb.transferObjects([nft], txb.pure.address(account.address));

txb.setGasBudget(100000000);

signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
showObjectChanges: true,
},
},
{
onSuccess: (tx) => {
client
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
toast.success("NFT bought successfully.", {
autoClose: 3000,
position: "bottom-right",
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false,
draggable: false,
});
onBuy();
});
},
},
);
};

return buy;
};

Notice that we get the output of the moveCall, and then we transfer it to a specific address using txb.transferObjects([nft], txb.pure.address(account.address)).

5 Cancel a listed NFT

First of all, only the address that listed the NFT can cancel the listing. Therefore, in the UI, we need to show the cancel button only if it’s the owner of the listing.

 {nft.owner === account?.address && (
<button
className={`px-4 py-2 bg-red-500 text-white rounded-lg`}
onClick={() => cancelListing(nft.id, nft.type)}
>
Cancel listing
</button>
)}

Let’s also look at the cancel_listing function signature.

   public fun cancel_listing<N: key + store>(
marketplace: &mut Marketplace,
nft_id: ID,
ctx: &mut TxContext
): N

Similar to the buy function, it also returns the NFT, which we will need to send back to the owner.

import { useCurrentAccount, useSignAndExecuteTransactionBlock, useSuiClient } from "@mysten/dapp-kit";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { useNetworkVariable } from "../networkConfig";
import { toast } from "react-toastify";

export const useCancelListing = (onSuccess: () => void) => {
const account = useCurrentAccount();
const client = useSuiClient();
const marketplacePackageId = useNetworkVariable("marketplacePackageId");
const marketplaceObjectId = useNetworkVariable("marketplaceObjectId");
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

const cancelListing = (nftId: string, type: string) => {
if (!account) {
return;
}

const txb = new TransactionBlock();

const nft = txb.moveCall({
arguments: [
txb.object(marketplaceObjectId),
txb.pure(nftId),
],
target: `${marketplacePackageId}::nft_marketplace::cancel_listing`,
typeArguments: [type]
});

txb.transferObjects([nft], txb.pure.address(account.address));

txb.setGasBudget(100000000);

signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
showObjectChanges: true,
},
},
{
onSuccess: (tx) => {
client
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
toast.success("Listing cancelled successfully.", {
autoClose: 3000,
position: "bottom-right",
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false,
draggable: false,
});
onSuccess();
});
},
onError: (e) => {
console.log({ e });
}
},
);
};

return cancelListing;
};

6 Bid on an NFT

public fun place_bid(marketplace: &mut Marketplace, nft_id: ID, coin: Coin<SUI>, ctx: &mut TxContext): ID

Similar to the buy function, but with the difference that it doesn’t return anything.

import {
useCurrentAccount,
useSignAndExecuteTransactionBlock,
useSuiClient,
} from "@mysten/dapp-kit";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { useNetworkVariable } from "../networkConfig";
import { toast } from "react-toastify";
import BigNumber from "bignumber.js";
import { MIST_PER_SUI } from "@mysten/sui.js/utils";

export const usePlaceBid = (onPlaceBid: () => void) => {
const account = useCurrentAccount();
const client = useSuiClient();
const marketplacePackageId = useNetworkVariable("marketplacePackageId");
const marketplaceObjectId = useNetworkVariable("marketplaceObjectId");
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

const placeBid = (nftId: string, price: string) => {
if (!account) {
return;
}
const txb = new TransactionBlock();

const [coin] = txb.splitCoins(txb.gas, [
new BigNumber(price).multipliedBy(MIST_PER_SUI.toString()).toFixed(),
]);

txb.moveCall({
arguments: [txb.object(marketplaceObjectId), txb.pure(nftId), coin],
target: `${marketplacePackageId}::nft_marketplace::place_bid`,
});

txb.setGasBudget(100000000);

signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
showObjectChanges: true,
},
},
{
onSuccess: (tx) => {
client
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
console.log({ tx });
toast.success("Bid placed with success.", {
autoClose: 1500,
position: "bottom-right",
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false,
draggable: false,
});
onPlaceBid();
});
},
},
);
};

return placeBid;
};

7 Display all bids

  public struct Bid has key, store {
id: UID,
nft_id: ID,
balance: Balance<SUI>,
owner: address,
}

We can get the bids in the same way we get the listings, by retrieving the contents of the marketplace object ID using sui_getObject.

Having all the bid ids, we can then get the details for each of them by using multiGetObjects RPC call. Since we are in the details screen of a specific NFT, we want to filter and show only the bids for that specific NFT. Each bid object has a ‘name’ field, which is actually the NFT id so we can filter by this field. Then, once we have the bid object of an NFT, we can iterate through the ‘value’ field which contains all bid details for this NFT.

import { useSuiClientQuery } from "@mysten/dapp-kit";
import { useMemo } from "react";
import { Bid } from "../types";

export const useGetBidsDetails = (
objectsIds: string[],
nftId: string | undefined,
) => {
const {
data: bidsData,
isPending,
error,
fetchStatus,
} = useSuiClientQuery(
"multiGetObjects",
{
ids: objectsIds,
options: {
showContent: true,
showOwner: true,
},
},
{ enabled: nftId !== undefined && objectsIds.length > 0 },
);

const bidsNeeded = useMemo(() => {
if (!bidsData) return undefined;

return bidsData
.filter(item => (item?.data?.content as any)?.fields?.name === nftId)
.flatMap(item => {
const { fields } = (item?.data?.content as any);
return fields?.value?.map((bid: any) => {
return {
bidId: bid.fields.id.id,
balance: bid.fields.balance,
owner: bid.fields.owner,
nft_id: bid.fields.nft_id,
};
});
});
}, [bidsData, nftId]);

return { data: bidsNeeded as Bid[], isPending: isPending && fetchStatus !== "idle", error };
};

8 Cancel bid

Only the owner of the bid will be able to cancel it. As we can see above, each bid also has the owner’s address, which we can use to verify this.

public fun cancel_bid(marketplace: &mut Marketplace, nft_id: ID, bid_id: ID, ctx: &mut TxContext): Coin<SUI> 

This transaction will be similar to the others, with the only difference being that the output is a Coin. Because of this, we need to transfer the coins to the address that placed the bid.

txb.transferObjects([coin], txb.pure(address));

9 Accept bid

Only the listing owner is able to accept bids, so we will show this button only to the listing owner, as described in the Cancel a Listed NFT section.

public fun accept_bid<N: key + store>(
marketplace: &mut Marketplace,
nft_id: ID,
bid_id: ID,
ctx: &mut TxContext
): Coin<SUI>

This will be similar to the cancel_bid transaction. Since it has an output, we need to send the coins to the address that listed the NFT.

Additional Thoughts

Developing our first dApp on Sui has been quite the journey. We’ve delved deep into Sui’s Object Model and navigated its unique challenges. Adapting to Sui’s structure wasn’t easy, but the experience has been worthwhile. We’ve learned a lot about what makes Sui different and how we can leverage its features.

Once we overcame the initial learning curve, building on Sui became progressively smoother. The documentation, though it could benefit from better organization and more advanced examples, provided a solid foundation for our project. Sui’s ability to process transactions in parallel, avoiding a global state, is a standout feature that significantly enhances performance. This capability, combined with Sui’s object-based model, positions it as a highly efficient platform for developing scalable dApps. We’re enthusiastic about the streamlined development process and the robust performance of our NFT marketplace.

One thing that stood out to us is Sui’s object-centric model. It offers a new way to manage data in dApps, and while it was different from what we’re used to, it brought several performance benefits. The built-in standards like the Coin standard made creating fungible tokens straightforward, giving us a solid framework to build on.

Looking ahead, we want to take full advantage of what Sui offers. The success of our NFT marketplace has given us the confidence to tackle more complex projects. We envision building more sophisticated dApps that fully utilize Sui’s parallel processing and object model to deliver innovative solutions. We are particularly interested in exploring shared objects and their potential for creating more interactive and dynamic applications.

Conclusion

Our journey with Sui wasn’t without challenges, but it was definitely rewarding. Moving from platforms to Sui meant we had to rethink our approach and learn new concepts. But this effort paid off — we’ve successfully built a functional NFT marketplace and learned a lot along the way.

Throughout the process, we faced hurdles, especially with documentation that could be clearer. Yet, these challenges pushed us to dig deeper and adapt.

Our NFT marketplace showcased the practical applications of Sui’s features. We had to think creatively to implement custom solutions when existing tools weren’t enough, proving that innovation is key in this space.

Looking ahead, we’re committed to exploring new possibilities and pushing the boundaries of what’s achievable with Sui. This journey is just beginning, and we can’t wait to see where it leads us.

Full source code can be found here: https://github.com/buidly/sui-nft-marketplace

DEMO dApp: https://sui-demo.buidly.com

Resources & Useful Links:

--

--