Sui.

Post

Share your knowledge.

BigLoba.
Sep 19, 2025
Expert Q&A

Time-Dependent Logic in Sui Smart Contracts

What’s the best way to encode time-dependent logic in Sui smart contracts (e.g., vesting schedules) without relying on unreliable external timestamp oracles?

  • Sui
0
1
Share
Comments
.

Answers

1
Big Mike.
Sep 20 2025, 00:32

I’ve run into this a lot when designing modules like vesting schedules, auctions, or lending protocols where I want time-dependent logic but can’t just trust an external timestamp oracle. Sui doesn’t provide block timestamps (because they’re non-deterministic across validators), so we need to rely on deterministic, consensus-available primitives. Here’s how I approach it:


🔑 Principles for Encoding Time in Sui

  1. Use Epochs, Not Timestamps

    • Sui exposes the current epoch as part of the system state.
    • Epochs are monotonically increasing and agreed upon by validators, so they’re safe for deterministic smart contract logic.
  2. Encode Relative, Not Absolute Time

    • Instead of saying “unlock at 2025-10-01,” I encode “unlock after N epochs from creation.”
    • This ensures the schedule works deterministically no matter when the contract was deployed.
  3. Capability-Gated Access

    • Vesting/release functions require a capability object (e.g., VestingCap) to enforce only the intended party can unlock assets.

🏗️ Example: Vesting Schedule in Move

module vesting::schedule {

    use sui::tx_context::TxContext;
    use sui::object::{Self, UID};
    use sui::clock;

    /// Vesting contract object
    struct Vesting has key {
        id: UID,
        beneficiary: address,
        start_epoch: u64,
        cliff_epoch: u64,
        duration_epochs: u64,
        total_amount: u64,
        claimed_amount: u64,
    }

    /// Capability for the beneficiary
    struct VestingCap has drop {}

    /// Create a new vesting schedule
    public entry fun create_vesting(
        beneficiary: address,
        cliff_epochs: u64,
        duration_epochs: u64,
        total_amount: u64,
        ctx: &mut TxContext
    ): (Vesting, VestingCap) {
        let current_epoch = clock::epoch(ctx);
        (
            Vesting {
                id: object::new(ctx),
                beneficiary,
                start_epoch: current_epoch,
                cliff_epoch: current_epoch + cliff_epochs,
                duration_epochs,
                total_amount,
                claimed_amount: 0,
            },
            VestingCap {}
        )
    }

    /// Claim vested tokens
    public entry fun claim(
        vesting: &mut Vesting,
        cap: &VestingCap,
        ctx: &TxContext
    ): u64 {
        let current_epoch = clock::epoch(ctx);

        // Must be past cliff
        assert!(current_epoch >= vesting.cliff_epoch, 1);

        // Calculate vested portion
        let elapsed = current_epoch - vesting.start_epoch;
        let vested_amount = if (elapsed >= vesting.duration_epochs) {
            vesting.total_amount
        } else {
            (vesting.total_amount * elapsed) / vesting.duration_epochs
        };

        // Calculate claimable
        let claimable = vested_amount - vesting.claimed_amount;
        vesting.claimed_amount = vested_amount;

        claimable
    }
}

⚡ How This Works

  • clock::epoch(ctx) gives me the deterministic epoch number (like a secure block counter).
  • I define the vesting schedule in epochs, not seconds.
  • claim checks how many epochs have passed since start_epoch, calculates the vested amount, and releases only what’s available.
  • Assets (e.g., SUI or custom coin types) would be transferred using this claim function.

🚀 Extensions

  • Epoch Anchoring: Use epochs as “time slices” (e.g., 1 epoch = 24 hours).
  • Programmable Cliffs: Allow multiple cliffs (good for DAO vesting).
  • Composable Vesting NFTs: Represent a Vesting object as an NFT that can be transferred, enabling secondary markets in locked positions.
  • Fairness via Validators: Epoch transitions are agreed upon at consensus, so they’re not manipulable by a single actor.

Summary (my dev POV): I never rely on wall-clock time in Sui contracts. Instead, I build epoch-based schedules that are deterministic and safe under consensus. For vesting, auctions, or rentals, I encode relative epoch offsets (cliffs, durations) and enforce release logic through capability checks.


Let’s take the epoch-based vesting logic I showed before and extend it so each vesting schedule is wrapped as an NFT. This way, users can trade, transfer, or even collateralize their vesting rights without breaking safety.


🔑 Design Principles

  1. NFT as Vesting Handle

    • Represent each vesting contract as a transferable NFT.
    • Whoever owns the NFT owns the right to claim the vested tokens.
  2. Keep Capability Security

    • Instead of giving a separate VestingCap, make the NFT itself the capability.
    • Only the holder of the NFT can call claim.
  3. Composable & Tradable

    • NFT can be sold on Sui NFT marketplaces.
    • Vesting logic remains intact no matter who holds it.

🏗️ NFT-Based Vesting in Move

module vesting::nft_vesting {

    use sui::tx_context::TxContext;
    use sui::object::{Self, UID};
    use sui::clock;

    /// NFT that represents ownership of a vesting schedule
    struct VestingNFT has key, store {
        id: UID,
        name: vector<u8>,
        description: vector<u8>,
        image_url: vector<u8>,     // optional NFT metadata
        vesting: Vesting,          // embedded vesting data
    }

    /// Core vesting data
    struct Vesting has store {
        beneficiary: address,      // original beneficiary (for reference)
        start_epoch: u64,
        cliff_epoch: u64,
        duration_epochs: u64,
        total_amount: u64,
        claimed_amount: u64,
    }

    /// Create a new vesting NFT
    public entry fun create_vesting_nft(
        beneficiary: address,
        cliff_epochs: u64,
        duration_epochs: u64,
        total_amount: u64,
        name: vector<u8>,
        description: vector<u8>,
        image_url: vector<u8>,
        ctx: &mut TxContext
    ): VestingNFT {
        let current_epoch = clock::epoch(ctx);

        let vesting = Vesting {
            beneficiary,
            start_epoch: current_epoch,
            cliff_epoch: current_epoch + cliff_epochs,
            duration_epochs,
            total_amount,
            claimed_amount: 0,
        };

        VestingNFT {
            id: object::new(ctx),
            name,
            description,
            image_url,
            vesting,
        }
    }

    /// Claim vested tokens (only NFT owner can do this)
    public entry fun claim(
        nft: &mut VestingNFT,
        ctx: &TxContext
    ): u64 {
        let current_epoch = clock::epoch(ctx);

        // Must be past cliff
        assert!(current_epoch >= nft.vesting.cliff_epoch, 1);

        // Calculate vested portion
        let elapsed = current_epoch - nft.vesting.start_epoch;
        let vested_amount = if (elapsed >= nft.vesting.duration_epochs) {
            nft.vesting.total_amount
        } else {
            (nft.vesting.total_amount * elapsed) / nft.vesting.duration_epochs
        };

        // Claimable = vested - already claimed
        let claimable = vested_amount - nft.vesting.claimed_amount;
        nft.vesting.claimed_amount = vested_amount;

        claimable
    }
}

⚡ How This Works

  • VestingNFT is a key object, so it lives in the owner’s account.
  • If the NFT is transferred, the new owner inherits the right to claim vested tokens.
  • The vesting math is the same as before (epoch-based cliff + linear unlock).
  • claim returns the claimable amount (e.g., could be used to mint or transfer SUI or another coin type).

🌍 Extensions

  • Composable Markets: These NFTs can be listed on Sui marketplaces (allowing secondary markets in locked tokens).
  • Split Vesting Rights: Users could split one vesting NFT into multiple fractional NFTs (think tranches).
  • Collateral Use: Vesting NFTs can be deposited into lending protocols as collateral while still enforcing unlock rules.
  • Metadata Hooks: The NFT metadata (name, description, image_url) makes vesting positions more user-friendly in wallets.

Summary (my dev POV): By encoding vesting schedules as NFTs with embedded epoch-based logic, I enable full composability in the Sui ecosystem. Holders can transfer or trade their locked positions, while the protocol enforces safe, deterministic unlocking under Move’s capability model.


Let’s consolidate everything into one clean Sui Move module:

This single module supports:

  • Creating vesting NFTs (with a beneficiary, schedule, and vault).
  • Claiming vested tokens based on epoch logic.
  • Splitting NFTs into fractional tranches.
  • Merging NFTs back if their schedules match.

🏗️ Full vesting::vesting_nft Module

module vesting::vesting_nft {

    use sui::tx_context::TxContext;
    use sui::object::{Self, UID};
    use sui::coin::{Self, Coin, split, join, value};
    use sui::clock;

    /// Vesting NFT metadata + vault
    struct VestingNFT<phantom T> has key, store {
        id: UID,
        name: vector<u8>,
        description: vector<u8>,
        image_url: vector<u8>,
        vesting: Vesting,
        vault: Coin<T>,
    }

    /// Vesting schedule
    struct Vesting has store {
        beneficiary: address,
        start_epoch: u64,
        cliff_epoch: u64,
        duration_epochs: u64,
        total_amount: u64,
        claimed_amount: u64,
    }

    /// --- 🔹 Create a vesting NFT
    public entry fun create_vesting_nft<T>(
        name: vector<u8>,
        description: vector<u8>,
        image_url: vector<u8>,
        beneficiary: address,
        start_epoch: u64,
        cliff_epoch: u64,
        duration_epochs: u64,
        deposit: Coin<T>,
        ctx: &mut TxContext
    ): VestingNFT<T> {
        let amount = value(&deposit);

        VestingNFT {
            id: object::new(ctx),
            name,
            description,
            image_url,
            vesting: Vesting {
                beneficiary,
                start_epoch,
                cliff_epoch,
                duration_epochs,
                total_amount: amount,
                claimed_amount: 0,
            },
            vault: deposit,
        }
    }

    /// --- 🔹 Claim vested tokens
    public entry fun claim<T>(
        nft: &mut VestingNFT<T>,
        clock: &clock::Clock,
        ctx: &mut TxContext
    ): Coin<T> {
        let epoch = clock::epoch(clock);

        // Nothing before start
        if (epoch < nft.vesting.start_epoch) {
            return coin::zero(ctx);
        };

        // If before cliff, nothing claimable
        if (epoch < nft.vesting.cliff_epoch) {
            return coin::zero(ctx);
        };

        // Compute vested fraction
        let elapsed = epoch - nft.vesting.start_epoch;
        let vested_amount =
            if (elapsed >= nft.vesting.duration_epochs) {
                nft.vesting.total_amount
            } else {
                nft.vesting.total_amount * elapsed / nft.vesting.duration_epochs
            };

        let claimable = vested_amount - nft.vesting.claimed_amount;

        if (claimable == 0) {
            return coin::zero(ctx);
        };

        nft.vesting.claimed_amount = nft.vesting.claimed_amount + claimable;
        split(&mut nft.vault, claimable, ctx)
    }

    /// --- 🔹 Split vesting NFT into two
    public entry fun split_vesting_nft<T>(
        nft: &mut VestingNFT<T>,
        split_amount: u64,
        ctx: &mut TxContext
    ): VestingNFT<T> {
        assert!(split_amount < nft.vesting.total_amount, 1);

        // Update original
        nft.vesting.total_amount = nft.vesting.total_amount - split_amount;

        // Create new coin vault
        let new_vault = split(&mut nft.vault, split_amount, ctx);

        // Create new NFT
        VestingNFT {
            id: object::new(ctx),
            name: nft.name,
            description: nft.description,
            image_url: nft.image_url,
            vesting: Vesting {
                beneficiary: nft.vesting.beneficiary,
                start_epoch: nft.vesting.start_epoch,
                cliff_epoch: nft.vesting.cliff_epoch,
                duration_epochs: nft.vesting.duration_epochs,
                total_amount: split_amount,
                claimed_amount: 0,
            },
            vault: new_vault,
        }
    }

    /// --- 🔹 Merge two vesting NFTs
    public entry fun merge_vesting_nfts<T>(
        target: &mut VestingNFT<T>,
        other: VestingNFT<T>,
    ) {
        // Ensure schedules match
        assert!(target.vesting.beneficiary == other.vesting.beneficiary, 10);
        assert!(target.vesting.start_epoch == other.vesting.start_epoch, 11);
        assert!(target.vesting.cliff_epoch == other.vesting.cliff_epoch, 12);
        assert!(target.vesting.duration_epochs == other.vesting.duration_epochs, 13);

        // Merge state
        target.vesting.total_amount = target.vesting.total_amount + other.vesting.total_amount;
        target.vesting.claimed_amount = target.vesting.claimed_amount + other.vesting.claimed_amount;

        // Merge vaults
        join(&mut target.vault, other.vault);

        // Burn other NFT
        object::delete(other.id);
    }
}

⚡ Features Recap

  • Create: Anyone can deposit coins to mint a new vesting NFT for a beneficiary.
  • Claim: Beneficiary withdraws vested funds per epoch logic.
  • Split: Fractionalize a vesting NFT into smaller tranches for transfer/trade.
  • Merge: Consolidate two NFTs back into one, if they share identical schedules.

Nice — I’ll add a safe, composable marketplace layer so vesting NFTs can be listed, bought, and transferred while preserving their vesting schedules and safety guarantees.

Goals I follow:

  • The NFT remains the single capability for claiming; transferring the NFT transfers claim rights.
  • Listings are resources tied to the seller + NFT; buying is atomic (buyer coin → seller, NFT → buyer).
  • Marketplace supports optional fees/royalties and cancel-listing.
  • Concurrency-safety: every relevant object is acquiresd in the same entry function so Sui’s object-level locking makes operations atomic and race-free.
  • Minimal trust: marketplace code only moves listed NFTs and coins; no extra custody of the NFT vault is left behind.

Below I produce:

  1. an updated Move module (extensions added to the vesting::vesting_nft module),
  2. brief TS client flow (how to list/buy),
  3. security notes & invariants to check with Move Prover.

1) Move: Marketplace extensions (add to your existing vesting::vesting_nft module)

Add these types & entry functions: Listing resource, list_for_sale, buy_listing, cancel_listing. I use generic coin T for payment token so the marketplace can use SUI or any Coin. Replace helper names (e.g., object::delete) with the actual Sui stdlib calls in your SDK/version.

module vesting::vesting_nft {

  use sui::tx_context::TxContext;
  use sui::object::{Self, UID};
  use sui::coin::{Self, Coin, split, join, value};
  use sui::clock;

  // --- VestingNFT and Vesting types (unchanged) ---
  struct VestingNFT<phantom T> has key, store {
    id: UID,
    name: vector<u8>,
    description: vector<u8>,
    image_url: vector<u8>,
    vesting: Vesting,
    vault: Coin<T>,
  }

  struct Vesting has store {
    beneficiary: address,
    start_epoch: u64,
    cliff_epoch: u64,
    duration_epochs: u64,
    total_amount: u64,
    claimed_amount: u64,
  }

  // --- Marketplace types ---
  /// A Listing resource represents an NFT put up for sale by a seller
  /// listing_price is the asking price in Coin<P> (payment token)
  struct Listing<phantom P> has key {
    id: UID,                   // listing object id
    nft_owner: address,        // seller address (owner at listing time)
    nft_obj_id: address,       // address of the VestingNFT object (object id)
    price: u64,                // price amount (we store as u64 for simplicity)
    payment_token_marker: address, // optional marker for token type; depends on your token registry
    fee_basis_points: u64,     // marketplace fee in basis points (e.g., 250 = 2.5%)
    royalty_basis_points: u64, // royalty to original issuer, optional
  }

  // -------- Helpers (pseudo) --------
  // get_object_owner: returns current owner address of an object id. Implementation depends on Sui stdlib.
  native fun get_object_owner(obj_id: address): address;

  // transfer_vesting_nft: move the listed VestingNFT from seller -> buyer.
  // Implementation notes: This will be done by passing the NFT object in the tx arguments (Sui model).
  // The function signature below assumes the caller includes the NFT object as the first param: `nft: VestingNFT<P>`
  // so no special native helper is required in that case.

  // -------- Marketplace entrypoints --------

  /// Seller creates a listing for a VestingNFT they own.
  /// `nft` must be the actual VestingNFT object (so owner must include it in transaction).
  public entry fun list_for_sale<P, PayT>(
    seller: &signer,
    mut nft: VestingNFT<P>,
    price: u64,
    fee_basis_points: u64,
    royalty_basis_points: u64,
    ctx: &mut TxContext
  ): Listing<PayT> {
    let seller_addr = signer::address_of(seller);

    // ownership check: Require seller is current owner of the NFT. Because NFT was provided as `nft: VestingNFT<P>`,
    // the runtime ensures seller included the object. Good pattern is to pass object as owned parameter.
    // Create listing
    let listing = Listing {
      id: object::new(ctx),
      nft_owner: seller_addr,
      nft_obj_id: /* nft.id */ object::id(&nft), // pseudo: extract object id
      price,
      payment_token_marker: /* optional */ @0x0,
      fee_basis_points,
      royalty_basis_points,
    };

    // We must keep NFT somewhere accessible for sale. Options:
    // 1) Move NFT into the listing object (listing owns the NFT) — safe and simple.
    // 2) Keep NFT with seller but mark listed flag — more complex concurrency semantics.
    // We choose (1) for atomicity: embed the NFT into the listing so buying simply moves listing.nft -> buyer.
    //
    // To do that, change Listing to include the NFT resource rather than only its id.
    // For simplicity in this snippet, assume listing will own the nft. Let's adapt Listing accordingly below.
    //
    // Clean approach: create Listing that contains the VestingNFT:
    // move listing.nft = nft; return listing
    //
    // (Because Move typing is strict, actually define Listing with a `nft: VestingNFT<P>` field.)
    //
    listing
  }

  // Revised Listing definition (preferred)
  struct ListingV2<phantom P, phantom PayT> has key {
    id: UID,
    seller: address,
    nft: VestingNFT<P>,
    price: u64,
    fee_basis_points: u64,
    royalty_basis_points: u64,
    payment_token_marker: address,
  }

  /// Create listing that *owns* the NFT inside the listing (atomically moves NFT into listing)
  public entry fun list_for_sale_v2<P, PayT>(
    seller: &signer,
    nft: VestingNFT<P>,   // seller passes ownership of nft into this call
    price: u64,
    fee_basis_points: u64,
    royalty_basis_points: u64,
    ctx: &mut TxContext
  ): ListingV2<P, PayT> {
    let seller_addr = signer::address_of(seller);
    ListingV2 {
      id: object::new(ctx),
      seller: seller_addr,
      nft,
      price,
      fee_basis_points,
      royalty_basis_points,
      payment_token_marker: @0x0
    }
  }

  /// Buyer purchases the listing by paying a Coin<PayT>. This call is atomic and `acquires` the Listing (which owns the NFT).
  /// Payment coin is included by the buyer in the transaction as `payment: Coin<PayT>`.
  public entry fun buy_listing<P, PayT>(
    buyer: &signer,
    listing: ListingV2<P, PayT>,   // buyer provides the listing object (owned by marketplace)
    mut payment: Coin<PayT>,
    ctx: &mut TxContext
  ) {
    let buyer_addr = signer::address_of(buyer);

    // 1) price check
    let price = listing.price;
    assert!(value(&payment) >= price, 100);

    // 2) compute marketplace fee & royalty
    let fee = (price * listing.fee_basis_points) / 10_000;
    let royalty = (price * listing.royalty_basis_points) / 10_000;
    let seller_amount = price - fee - royalty;

    // 3) split payment: extract `price` from payment coin into pieces
    let (price_coin, rest) = split_payment_exact(&mut payment, price, ctx);
    // price_coin contains exactly `price`, rest returned to buyer later
    // split `price_coin` into seller_coin, fee_coin, royalty_coin
    let (seller_coin, temp) = split(&mut price_coin, seller_amount, ctx);
    let (fee_coin, royalty_coin) = split(&mut temp, fee, ctx); // temp is now price - seller_amount

    // 4) transfer amounts:
    // send seller_coin to listing.seller
    // send fee_coin to marketplace fee receiver (e.g., a hardcoded address)
    // send royalty_coin to original issuer if needed (we omit issuer tracking here - add field if required)

    // For transfers, in Sui Move, you typically return coins to the appropriate signers/recipients.
    // In this template we assume transfer_by_address helper exists (or the caller constructs separate txs to move coins).
    transfer_coin_to_address(seller_coin, listing.seller);
    transfer_coin_to_address(fee_coin, @0xMarketplaceFeeReceiver);
    // if royalty exists:
    if (royalty > 0) {
      transfer_coin_to_address(royalty_coin, @0xRoyaltyReceiver);
    }

    // 5) transfer NFT to buyer: listing.nft is moved into buyer's ownership by returning it as result or by moving to buyer in this tx.
    let nft_to_transfer = listing.nft;
    // Destroy listing object (since nft moved)
    object::delete(listing.id);

    // The buyer now owns nft_to_transfer (returned as result of entry function)
    // Move requires a return value to actually hand nft to caller:
    // So change signature to `): VestingNFT<P>` and `return nft_to_transfer;`
  }

  /// Cancel a listing and return NFT to seller (only seller can cancel)
  public entry fun cancel_listing<P, PayT>(
    seller: &signer,
    listing: ListingV2<P, PayT>,
    ctx: &mut TxContext
  ) {
    let seller_addr = signer::address_of(seller);
    assert!(listing.seller == seller_addr, 200);
    // Return NFT to seller by returning listing.nft as result and destroy listing.id
    let nft_back = listing.nft;
    object::delete(listing.id);
    // return nft_back;
  }

  // ---------------- Utility helpers (pseudocode) ----------------

  /// Split exactly `amount` from a coin; returns (extractedCoin, remainderCoin)
  fun split_payment_exact<PayT>(coin: &mut Coin<PayT>, amount: u64, ctx: &mut TxContext): (Coin<PayT>, Coin<PayT>) {
    // Implementation depends on coin::split function semantics; if only split by amount exists, use that.
    // Placeholder pseudo:
    let extracted = split(coin, amount, ctx);
    let remainder = /* coin now mutated as remainder */;
    (extracted, remainder)
  }

  /// Transfer coin to given address (pseudocode). Actual Sui pattern: return coin to caller or use `transfer::transfer`
  native fun transfer_coin_to_address<PayT>(c: Coin<PayT>, recipient: address);

}

Notes about the Move snippet

  • I show ListingV2 as the safe pattern: the listing owns the NFT (so there is a single authoritative object for sale). This avoids races where seller tries to move the NFT while a buyer submits purchase. list_for_sale_v2 takes ownership of the NFT and places it inside the ListingV2 object.
  • buy_listing is atomic: it acquires the listing (which includes NFT) and the buyer includes payment coin. The function checks payment, splits coin into seller/fee/royalty, moves coins to recipients, and returns the NFT to buyer.
  • On Sui you must return the NFT resource to hand it to buyer or perform a transfer primitive. The Move signature should therefore actually return VestingNFT<P> to give it to the buyer. I used inline pseudocode to show flow — adapt the function return types in your environment.
  • Replace transfer_coin_to_address and object::id usage with actual Sui stdlib helpers (Sui Move stdlib evolves; adapt accordingly).

2) TypeScript client flows

A quick overview of how clients interact (using @mysten/sui.js flows):

  • List for sale

    1. Seller constructs a TransactionBlock.
    2. Adds the VestingNFT object as an input (the object to be moved).
    3. Calls list_for_sale_v2 with price, fee, royalty, returning a ListingV2 object.
    4. Sign & submit.
  • Buy listing

    1. Buyer fetches listing object id and price.
    2. Buyer constructs TransactionBlock, includes ListingV2 object id and a Coin<PayT> object with at least price.
    3. Calls buy_listing, which returns the VestingNFT to the buyer.
    4. Sign & submit. Gas sponsorship or relayer possible.
  • Cancel listing

    1. Seller constructs tx including ListingV2 object id.
    2. Calls cancel_listing, which returns the VestingNFT back to seller.
    3. Sign & submit.

Make sure all calls include the actual objects (VestingNFT or ListingV2) as arguments so Sui locks them atomically.


3) Concurrency & safety guarantees

  • Atomicity: because the listing owns the NFT, buy_listing acquires the listing object and the buyer’s payment coin — Sui locks both objects during execution. No other tx can concurrently move the NFT or listing; either the buy completes or fails.
  • No ghost transfers: NFT vault inside the VestingNFT moves with the NFT; claim logic still works for the new owner seamlessly.
  • Reentrancy: Move and Sui’s single-writer model avoids classical reentrancy issues; still test for logical reentrancy (e.g., buy triggers event which off-chain watcher interprets incorrectly).
  • Fees & royalties: computed in-module and transferred atomically as part of the purchase.
  • Seller must own NFT to list: since list_for_sale_v2 requires nft: VestingNFT<P> parameter (owned object), only current owner can call it.

4) Invariants & Move Prover checks to add

Add spec module invariants and prove with Move Prover:

  1. Listing owns NFT: For any ListingV2, the nft field is non-null and not referenced elsewhere.
  2. No double-sell: A VestingNFT cannot be in two ListingV2 objects at once (resource typing helps).
  3. Payment conservation: Sum of payment splits (seller + fee + royalty) equals price.
  4. Postconditions of buy_listing: After successful buy, listing is destroyed and buyer is owner of NFT.

These keep marketplace logic auditable and provable.


5) Operational tips

  • Index events: Emit events for Listed, Bought, Canceled. Indexers monitor and update off-chain marketplace UI.
  • Delist on transfer: Disallow transferring a listed NFT outside marketplace unless listing is canceled first (or auto-cancel listing on transfer). Enforce by requiring listing ownership to be a nested resource that prevents external transfer while listed.
  • Fee & royalty receivers: Use explicit on-chain register addresses so marketplace operator or creators can change recipients through governance.
  • Front-running: Because the listing owns the NFT, front-running by an attacker who sees signed buy tx is limited — include nonces or require the buyer to provide the exact listing id and price to reduce MEV risk. Consider signed offers (off-chain signed orders) and on-chain fill patterns for matching engines if you need order-book style market.
  • Off-chain orderbooks: For improved UX/scale, keep off-chain orderbook of signed list messages and call list_for_sale_v2 only when the seller wants to lock their NFT on-chain. Or keep NFT on-chain in listing and allow off-chain discovery.

6) Example quick test scenarios to run

  1. Seller lists NFT A for 100 tokens; buyer buys with exactly 100 — assert:

    • buyer now owns NFT A,
    • seller received 100 - fees - royalties,
    • listing object destroyed.
  2. Two concurrent buyers submit buy txs — exactly one succeeds, the other fails due to object lock.

  3. Seller lists, then attempts to transfer NFT outside marketplace — should be impossible because listing owns NFT (unless you explicitly design otherwise).

  4. Seller cancels listing — NFT returns to seller.

0
Comments
.

Do you know the answer?

Please log in and share it.