Prompt Wars: NEAR Protocol Recipes
  • NEAR Protocol Recipes
    • NEAR Guest Wallet Widget
    • NEAR and NEP141 Balance Widget
    • Render a Data Table from Contract VIEW Methods
    • Link an Account to Image, Text and Time Metadata with On-Chain Storage
    • Create Time State Machines with Realtime Block Timestamp Comparisons
    • Handle NEP141 Transfer Calls and Track the Target Contract's Balance
    • Handle FunctionCall Transaction Errors
  • Project Setup
    • The User Interface
    • The Rust Contracts
Powered by GitBook
On this page
  • The ft_receiver contract
  • The ft_on_transfer msg Payload
  • Calling ft_transfer_call from the client-side
  • Unwrapping ft_on_transfer result
  • Displaying the game contract's balance
  • Formatting NEP141 Fungible Token balances
  • Fixed-point decimal amount format helpers
  1. NEAR Protocol Recipes

Handle NEP141 Transfer Calls and Track the Target Contract's Balance

Learn to use ft_transfer_call function calls. This method needs a bit of Rust logic to make it work with the target contract.

PreviousCreate Time State Machines with Realtime Block Timestamp ComparisonsNextHandle FunctionCall Transaction Errors

Last updated 1 year ago

Register the target address, transfer the minimum NEAR balance and make your dApp accept fungible token transfers to create engaging games with real money

Getting Started

Follow the instructions to start the project in your local machine and try the recipes yourself. You can also to get just what you need.

For a NEAR wallet to receive NEP141 payments, for example USDT usdt.tether-token.near  transfers, a simple ft_transfer call will suffice. If the source wallet has the necessary balance, the money will be transferred. But what if you want to notify another contract about this balance transfer and act upon it?

This is what we do in Prompt Wars. When a user submits a prompt, they are actually transferring their fungible token balance to the game contract, the game contract is "notified" and registers the player's prompt to list them in the game.

IMPORTANT

New contracts that should receive NEP141 fungible token transfers need to be registered.

The ft_receiver contract

Part of the Prompt Wars suite of methods, is the ft_on_transfer method. This method will be called after a successful ft_transfer_call call on the receiving NEP141 funginble token contract.

The important part here is the msg String parameter. Note that the NEP141 callback will strictly call ft_on_transfer on the receiver contract. This method should be publicly available on your contract.

The ft_on_transfer msg Payload

The pattern above expects a String as the msg parameter, this String is formatted by JSON.stringify and sent via the cross-contract call to be unwrapped by this line:

let payload: Payload = serde_json::from_str(&msg).expect("ERR_INVALID_PAYLOAD");

Rust does the work of checking the Payload structure and then passes it to the match block:

match payload {
    Payload::CreateOutcomeTokenArgs(payload) => {
        self.create_outcome_token(sender_id, amount, payload)
    }
};

Where self.create_outcome_token(sender_id, amount, payload) is part of the game's private contract methods.

Note that "private" here is actually NEAR's private, but not Rust private. Meaning that the method can be called across Rust files, but not called from the NEAR CLI or NEAR API.

Calling ft_transfer_call from the client-side

This whole pattern is used from the client-side when a user's wallet is connected. Notice how we format the msg parameter expected by ft_transfer_call:

Unwrapping ft_on_transfer result

If the NEP141 transfer succeeds, then there should be a new balance registered for the target contract, in this case our game.

To check for a successful transfer, remember that create_outcome_token returns an amount. This amount should be greater than 0 and should match the amount we are charging to play the game:

#[private]
pub fn create_outcome_token(
    &mut self,
    sender_id: AccountId,
    amount: WrappedBalance,
    payload: CreateOutcomeTokenArgs,
) -> WrappedBalance {
    self.assert_is_open();
    self.assert_is_not_resolved();
    self.assert_price(amount);

    let outcome_id = sender_id;
    let prompt = payload.prompt;

    match self.outcome_tokens.get(&outcome_id) {
        Some(_token) => env::panic_str("ERR_CREATE_OUTCOME_TOKEN_outcome_id_EXIST"),
        None => {
            self.internal_create_outcome_token(outcome_id, prompt, amount);
        }
    }

    amount
}

This amount is unwrapped on ftTransferCall .

Displaying the game contract's balance

This client-side step is the easiest. Just call ft_balance_of  to check the balance of a target accountId on any NEP141 fungible token:

Formatting NEP141 Fungible Token balances

Each fungible token has its own decimals value definition. For USDT for example, it is decimals: 6.

Remember that smart-contracts use fixed-point decimals (no . symbol to distinguish cents as in 1.0 ). For example:

NEAR_ENV=mainnet near view usdt.tether-token.near ft_metadata                                                                                                                    1 ↵

View call: usdt.tether-token.near.ft_metadata()
{
  spec: 'ft-1.0.0',
  name: 'Tether USD',
  symbol: 'USDt',
  icon: "data:image/svg+xml,%3Csvg width='111' height='90' viewBox='0 0 111 90' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M24.4825 0.862305H88.0496C89.5663 0.862305 90.9675 1.64827 91.7239 2.92338L110.244 34.1419C111.204 35.7609 110.919 37.8043 109.549 39.1171L58.5729 87.9703C56.9216 89.5528 54.2652 89.5528 52.6139 87.9703L1.70699 39.1831C0.305262 37.8398 0.0427812 35.7367 1.07354 34.1077L20.8696 2.82322C21.6406 1.60483 23.0087 0.862305 24.4825 0.862305ZM79.8419 14.8003V23.5597H61.7343V29.6329C74.4518 30.2819 83.9934 32.9475 84.0642 36.1425L84.0638 42.803C83.993 45.998 74.4518 48.6635 61.7343 49.3125V64.2168H49.7105V49.3125C36.9929 48.6635 27.4513 45.998 27.3805 42.803L27.381 36.1425C27.4517 32.9475 36.9929 30.2819 49.7105 29.6329V23.5597H31.6028V14.8003H79.8419ZM55.7224 44.7367C69.2943 44.7367 80.6382 42.4827 83.4143 39.4727C81.0601 36.9202 72.5448 34.9114 61.7343 34.3597V40.7183C59.7966 40.8172 57.7852 40.8693 55.7224 40.8693C53.6595 40.8693 51.6481 40.8172 49.7105 40.7183V34.3597C38.8999 34.9114 30.3846 36.9202 28.0304 39.4727C30.8066 42.4827 42.1504 44.7367 55.7224 44.7367Z' fill='%23009393'/%3E%3C/svg%3E",
  reference: null,
  reference_hash: null,
  decimals: 6
}

With decimals being 6, an amount of USDT 1.0 would be represented as:

1000000

But this would be confusing for your users, since they are used to the dot notation.

Fixed-point decimal amount format helpers

Luckily for you, Prompt Wars needed to display formatted amounts and we developed some helpers that may suit your dApp as well:

.

Read through to learn more about this pattern.

Note that the needs to be parsed because it is formatted as hex. Check this .

See a useful pattern here
NEAR's Fungible Token docs
response of wallet.signAndSendTransaction
unwrapping function helper here
🤑
Project Setup
clone or navigate the repo
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/contracts/prompt-wars/src/ft_receiver.rs#L17-L37
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/contracts/prompt-wars/src/contract.rs#L94-L117
use near_sdk::collections::Vector;
use near_sdk::{
    collections::LookupMap, env, ext_contract, json_types::U128, log, near_bindgen, AccountId,
    Promise,
};
use num_format::ToFormattedString;
use shared::OutcomeId;
use std::default::Default;

use near_contract_standards::fungible_token::core::ext_ft_core;

use crate::consts::*;
use crate::storage::*;

#[ext_contract(ext_self)]
trait Callbacks {
    fn on_ft_transfer_callback(
        &mut self,
        amount_payable: WrappedBalance,
        outcome_id: OutcomeId,
    ) -> String;
    fn on_claim_fees_resolved_callback(
        &mut self,
        payee: AccountId,
        amount_paid: WrappedBalance,
    ) -> Option<Timestamp>;
    fn on_claim_balance_self_destruct_callback(
        &mut self,
        payee: AccountId,
        amount_payable: WrappedBalance,
    ) -> Option<Timestamp>;
}

#[ext_contract(ext_feed_parser)]
trait SwitchboardFeedParser {
    fn aggregator_read(&self, msg: String) -> Promise;
}

impl Default for Market {
    fn default() -> Self {
        env::panic_str("ERR_MARKET_NOT_INITIALIZED")
    }
}

#[near_bindgen]
impl Market {
    #[init]
    pub fn new(
        market: MarketData,
        management: Management,
        collateral_token: CollateralToken,
    ) -> Self {
        if env::state_exists() {
            env::panic_str("ERR_ALREADY_INITIALIZED");
        }

        let starts_at: Timestamp = env::block_timestamp().try_into().unwrap();
        let ends_at: Timestamp = starts_at + EVENT_PERIOD_NANOS;
        let reveal_window = ends_at + STAGE_PERIOD_NANOS;
        let resolution_window = reveal_window + STAGE_PERIOD_NANOS;

        // 7 days
        let self_destruct_window = resolution_window + 604_800_000_000_000;

        Self {
            market: MarketData {
                starts_at,
                ends_at,
                ..market
            },
            outcome_tokens: LookupMap::new(StorageKeys::OutcomeTokens),
            players: Vector::new(b"p"),
            resolution: Resolution {
                window: resolution_window,
                reveal_window,
                resolved_at: None,
                result: None,
            },
            fees: Fees {
                price: CREATE_OUTCOME_TOKEN_PRICE,
                fee_ratio: FEE_RATIO,
                claimed_at: None,
            },
            collateral_token: CollateralToken {
                balance: 0,
                fee_balance: 0,
                // @TODO collateral_token_decimals should be set by a cross-contract call to ft_metadata
                ..collateral_token
            },
            management: Management {
                self_destruct_window,
                buy_sell_threshold: BUY_SELL_THRESHOLD,
                ..management
            },
        }
    }

    #[private]
    #[payable]
    pub fn create_outcome_token(
        &mut self,
        sender_id: AccountId,
        amount: WrappedBalance,
        payload: CreateOutcomeTokenArgs,
    ) -> WrappedBalance {
        self.assert_is_open();
        self.assert_is_not_resolved();
        self.assert_price(amount);

        let outcome_id = sender_id;
        let prompt = payload.prompt;

        match self.outcome_tokens.get(&outcome_id) {
            Some(_token) => env::panic_str("ERR_CREATE_OUTCOME_TOKEN_outcome_id_EXIST"),
            None => {
                self.internal_create_outcome_token(outcome_id, prompt, amount);
            }
        }

        amount
    }

    #[payable]
    pub fn sell(&mut self) -> WrappedBalance {
        if self.is_expired_unresolved() {
            return self.internal_sell_unresolved();
        }

        self.assert_is_resolved();

        return self.internal_sell_resolved();
    }

    // During the resolution period, this method is called by the server (owner), setting the OutcomeTokenResult of each player
    pub fn reveal(
        &mut self,
        outcome_id: OutcomeId,
        result: OutcomeTokenResult,
        output_img_uri: String,
    ) {
        self.assert_only_owner();
        self.assert_is_reveal_window_open();
        self.assert_is_not_resolved();
        self.assert_is_valid_outcome(&outcome_id);
        self.assert_is_resolution_window_open();

        let mut outcome_token = self.outcome_tokens.get(&outcome_id).unwrap();
        outcome_token.set_result(result);
        outcome_token.set_output_img_uri(output_img_uri);

        self.outcome_tokens.insert(&outcome_id, &outcome_token);

        log!("reveal: outcome_id: {}, result: {}", outcome_id, result);
    }

    // Gets the players accounts,
    // gets their outcome token results if they revealed on time
    // sorts the results, closest to 0 is first, closest to 0 wins
    #[payable]
    pub fn resolve(&mut self) {
        self.assert_only_owner();
        self.assert_is_not_resolved();
        self.assert_is_resolution_window_open();

        if self.get_outcome_ids().is_empty() {
            self.resolution.resolved_at = Some(self.get_block_timestamp());
            log!("resolve, market ended with 0 participants");
            return;
        }

        let separator = "=".to_string();

        let mut results: Vector<String> = Vector::new(b"r");

        for player in self.get_outcome_ids().clone() {
            let outcome_token = self.outcome_tokens.get(&player).unwrap();

            if outcome_token.result.is_none() {
                log!(
                    "resolve — PLAYER_DID_NOT_REVEAL — outcome_id: {}, result: {}",
                    outcome_token.outcome_id,
                    outcome_token.result.unwrap_or(0.0)
                );
            } else {
                let result_str = outcome_token.result.unwrap().to_string();

                results.push(&(player.to_string() + &separator + &result_str));
            }
        }

        log!("resolve, unsorted results: {:?}", results.to_vec());

        let mut sort = results.to_vec();
        sort.sort_by(|a, b| {
            let result_a = a.split(&separator).last().unwrap();
            let result_b = b.split(&separator).last().unwrap();

            return result_a.partial_cmp(result_b).unwrap();
        });

        log!("resolve, sorted results: {:?}", sort);

        // @TODO what happens if 2 or more players have exactly the same result?
        let winner = &sort[0];
        let result = winner.split(&separator).next().unwrap();

        log!("resolve, result: {}", result);

        self.internal_set_resolution_result(AccountId::new_unchecked(result.to_string()));
    }

    pub fn claim_fees(&mut self) {
        self.assert_is_resolution_window_expired();
        self.assert_fees_not_claimed();

        let payee = self.management.dao_account_id.clone();

        let amount_payable = self.collateral_token.fee_balance;

        if amount_payable == 0 {
            self.fees.claimed_at = Some(self.get_block_timestamp());
            env::panic_str("ERR_CLAIM_FEES_AMOUNT_PAYABLE_IS_ZERO");
        }

        let ft_transfer_promise = ext_ft_core::ext(self.collateral_token.id.clone())
            .with_attached_deposit(FT_TRANSFER_BOND)
            .with_static_gas(GAS_FT_TRANSFER)
            .ft_transfer(payee.clone(), U128::from(amount_payable), None);

        let ft_transfer_callback_promise = ext_self::ext(env::current_account_id())
            .with_attached_deposit(0)
            .with_static_gas(GAS_FT_TRANSFER_CALLBACK)
            .on_claim_fees_resolved_callback(payee, amount_payable);

        ft_transfer_promise.then(ft_transfer_callback_promise);
    }

    pub fn self_destruct(&mut self) {
        self.assert_only_owner();
        self.assert_is_resolution_window_expired();

        if !self.is_self_destruct_window_expired() {
            env::panic_str("ERR_SELF_DESTRUCT_WINDOW_NOT_EXPIRED");
        }

        if self.get_outcome_ids().is_empty() {
            env::panic_str("ERR_NO_OUTCOME_IDS");
        }

        if !self.fees.claimed_at.is_some() || self.collateral_token.fee_balance > 0 {
            env::panic_str("ERR_SELF_DESTRUCT_FEES_UNCLAIMED");
        }

        let amount_payable = self.collateral_token.balance;

        if amount_payable == 0 {
            Promise::new(env::current_account_id())
                .delete_account(self.management.market_creator_account_id.clone());

            return;
        }

        let payee = self.management.dao_account_id.clone();

        let ft_transfer_promise = ext_ft_core::ext(self.collateral_token.id.clone())
            .with_attached_deposit(FT_TRANSFER_BOND)
            .with_static_gas(GAS_FT_TRANSFER)
            .ft_transfer(payee.clone(), U128::from(amount_payable), None);

        let ft_transfer_callback_promise = ext_self::ext(env::current_account_id())
            .with_attached_deposit(0)
            .with_static_gas(GAS_FT_TRANSFER_CALLBACK)
            .on_claim_balance_self_destruct_callback(payee, amount_payable);

        ft_transfer_promise.then(ft_transfer_callback_promise);
    }

    #[private]
    pub fn update_collateral_token_balance(&mut self, amount: WrappedBalance) -> WrappedBalance {
        self.collateral_token.balance = amount;

        log!(
            "update_collateral_token_balance: {}",
            amount.to_formatted_string(&FORMATTED_STRING_LOCALE)
        );

        self.collateral_token.balance
    }

    #[private]
    pub fn update_collateral_token_fee_balance(
        &mut self,
        amount: WrappedBalance,
    ) -> WrappedBalance {
        self.collateral_token.fee_balance += amount;

        log!(
            "update_collateral_token_fee_balance, in: {}, total: {}",
            amount.to_formatted_string(&FORMATTED_STRING_LOCALE),
            self.collateral_token
                .fee_balance
                .to_formatted_string(&FORMATTED_STRING_LOCALE)
        );

        self.collateral_token.fee_balance
    }
}

impl Market {
    // Mint a new token for the player
    fn internal_create_outcome_token(
        &mut self,
        outcome_id: OutcomeId,
        prompt: String,
        amount: WrappedBalance,
    ) -> WrappedBalance {
        let (amount_mintable, fee) = self.get_amount_mintable(amount);

        let outcome_token = OutcomeToken::new(&outcome_id, prompt, amount_mintable);

        self.players.push(&outcome_id);

        self.outcome_tokens.insert(&outcome_id, &outcome_token);

        self.update_collateral_token_balance(self.collateral_token.balance + amount_mintable);
        self.update_collateral_token_fee_balance(fee);

        self.outcome_tokens.insert(&outcome_id, &outcome_token);

        log!("CREATE_OUTCOME_TOKEN amount: {}, fee_ratio: {}, fee_result: {}, outcome_id: {}, sender_id: {}, ot_supply: {}, amount_mintable: {}, ct_balance: {}, fee_balance: {}",
            amount.to_formatted_string(&FORMATTED_STRING_LOCALE),
            self.fees.fee_ratio.to_formatted_string(&FORMATTED_STRING_LOCALE),
            fee.to_formatted_string(&FORMATTED_STRING_LOCALE),
            outcome_token.outcome_id,
            outcome_id,
            outcome_token.total_supply().to_formatted_string(&FORMATTED_STRING_LOCALE),
            amount_mintable.to_formatted_string(&FORMATTED_STRING_LOCALE),
            self.collateral_token.balance.to_formatted_string(&FORMATTED_STRING_LOCALE),
            self.collateral_token.fee_balance.to_formatted_string(&FORMATTED_STRING_LOCALE),
        );

        amount_mintable
    }

    fn internal_sell_unresolved(&mut self) -> WrappedBalance {
        let payee = env::signer_account_id();

        let (amount_payable, _weight) = self.get_amount_payable_unresolved(payee.clone());

        self.internal_transfer(payee, amount_payable)
    }

    fn internal_sell_resolved(&mut self) -> WrappedBalance {
        let payee = self.resolution.result.clone().unwrap();

        self.assert_is_winner(&payee);

        let (amount_payable, _weight) = self.get_amount_payable_resolved();

        self.internal_transfer(payee, amount_payable)
    }

    fn internal_transfer(
        &mut self,
        payee: AccountId,
        amount_payable: WrappedBalance,
    ) -> WrappedBalance {
        if amount_payable <= 0 {
            env::panic_str("ERR_CANT_SELL_A_LOSING_OUTCOME");
        }

        let outcome_token = self.get_outcome_token(&payee);

        log!(
            "TRANSFER amount: {},  account_id: {}, supply: {}, is_resolved: {}, ct_balance: {}, amount_payable: {}",
            amount_payable.to_formatted_string(&FORMATTED_STRING_LOCALE),
            payee,
            outcome_token.total_supply().to_formatted_string(&FORMATTED_STRING_LOCALE),
            self.is_resolved(),
            self.collateral_token.balance.to_formatted_string(&FORMATTED_STRING_LOCALE),
            amount_payable.to_formatted_string(&FORMATTED_STRING_LOCALE),
        );

        let ft_transfer_promise = ext_ft_core::ext(self.collateral_token.id.clone())
            .with_attached_deposit(FT_TRANSFER_BOND)
            .with_static_gas(GAS_FT_TRANSFER)
            .ft_transfer(payee.clone(), U128::from(amount_payable), None);

        let ft_transfer_callback_promise = ext_self::ext(env::current_account_id())
            .with_attached_deposit(0)
            .with_static_gas(GAS_FT_TRANSFER_CALLBACK)
            .on_ft_transfer_callback(amount_payable, outcome_token.outcome_id);

        ft_transfer_promise.then(ft_transfer_callback_promise);

        amount_payable
    }

    fn internal_set_resolution_result(&mut self, result: ResolutionResult) {
        self.resolution.result = Some(result);
        self.resolution.resolved_at = Some(self.get_block_timestamp());

        log!(
            "internal_set_resolution_result, result: {:?}",
            self.resolution.result.clone().unwrap()
        );
    }
}
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/context/near/prompt-wars-market-contract/NearPromptWarsMarketContractContextController.tsx#L177-L204
import React, { useState } from "react";
import { setTimeout } from "timers";
import { useRouter } from "next/router";

import { useToastContext } from "hooks/useToastContext/useToastContext";
import { Typography } from "ui/typography/Typography";
import { PromptWarsMarketContract } from "providers/near/contracts/prompt-wars/contract";
import {
  GetOutcomeTokenArgs,
  Prompt,
  PromptWarsMarketContractStatus,
  PromptWarsMarketContractValues,
} from "providers/near/contracts/prompt-wars/prompt-wars.types";
import { useWalletStateContext } from "context/wallet/state/useWalletStateContext";
import { FungibleTokenContract } from "providers/near/contracts/fungible-token/contract";
import currency from "providers/currency";
import { useRoutes } from "hooks/useRoutes/useRoutes";
import { ErrorCode } from "providers/near/error-codes";

import {
  NearPromptWarsMarketContractContextContextActions,
  NearPromptWarsMarketContractContextControllerProps,
} from "./NearPromptWarsMarketContractContextContext.types";
import { NearPromptWarsMarketContractContext } from "./NearPromptWarsMarketContractContext";

let marketContract: PromptWarsMarketContract;

export const NearPromptWarsMarketContractContextController = ({
  children,
  marketId,
}: NearPromptWarsMarketContractContextControllerProps) => {
  const [marketContractValues, setMarketContractValues] = useState<PromptWarsMarketContractValues>();
  const [actions, setActions] = useState<NearPromptWarsMarketContractContextContextActions>({
    fetchMarketContractValues: {
      isLoading: false,
    },
    ftTransferCall: {
      success: false,
      isLoading: false,
    },
    create: {
      isLoading: false,
    },
  });

  const routes = useRoutes();
  const router = useRouter();

  const toast = useToastContext();

  const walletStateContext = useWalletStateContext();

  const getMarketStatus = (values: PromptWarsMarketContractValues): PromptWarsMarketContractStatus => {
    if (!values) {
      return PromptWarsMarketContractStatus.LOADING;
    }

    if (values?.isOpen) {
      return PromptWarsMarketContractStatus.OPEN;
    }

    if (values?.isOver && values.isResolved) {
      return PromptWarsMarketContractStatus.RESOLVED;
    }

    if (values?.isOver && !values.isRevealWindowExpired) {
      return PromptWarsMarketContractStatus.REVEALING;
    }

    if (values?.isOver && !values.isResolutionWindowExpired) {
      return PromptWarsMarketContractStatus.RESOLVING;
    }

    if (values?.isOver && values.isExpiredUnresolved) {
      return PromptWarsMarketContractStatus.UNRESOLVED;
    }

    return PromptWarsMarketContractStatus.CLOSED;
  };

  const fetchMarketContractValues = async () => {
    setActions((prev) => ({
      ...prev,
      fetchMarketContractValues: {
        isLoading: true,
      },
    }));

    try {
      // Wait 1 second to allow flags to change
      setTimeout(async () => {
        try {
          const contract = await PromptWarsMarketContract.loadFromGuestConnection(marketId);

          const [
            market,
            resolution,
            fees,
            management,
            collateralToken,
            outcomeIds,
            isResolved,
            isOpen,
            isOver,
            isRevealWindowExpired,
            isResolutionWindowExpired,
            isExpiredUnresolved,
          ] = await Promise.all([
            contract.get_market_data(),
            contract.get_resolution_data(),
            contract.get_fee_data(),
            contract.get_management_data(),
            contract.get_collateral_token_metadata(),
            contract.get_outcome_ids(),
            contract.is_resolved(),
            contract.is_open(),
            contract.is_over(),
            contract.is_reveal_window_expired(),
            contract.is_resolution_window_expired(),
            contract.is_expired_unresolved(),
          ]);

          const values: PromptWarsMarketContractValues = {
            market,
            resolution,
            fees,
            management,
            collateralToken,
            outcomeIds,
            isResolved,
            isOpen,
            isOver,
            isRevealWindowExpired,
            isResolutionWindowExpired,
            isExpiredUnresolved,
            status: PromptWarsMarketContractStatus.LOADING,
          };

          const status = getMarketStatus(values);

          values.status = status;

          setMarketContractValues(values);
        } catch (error) {
          console.log(error);
        }
      }, 1000);
    } catch {
      toast.trigger({
        variant: "error",
        withTimeout: true,
        title: "Failed to fetch market data",
        children: <Typography.Text>Try refreshing the page, or check your internet connection.</Typography.Text>,
      });
    }

    setActions((prev) => ({
      ...prev,
      fetchMarketContractValues: {
        isLoading: false,
      },
    }));
  };

  const assertWalletConnection = () => {
    if (!walletStateContext.isConnected) {
      toast.trigger({
        variant: "info",
        withTimeout: true,
        title: "Wallet is not connected",
        children: <Typography.Text>Check your internet connection, your NEAR balance and try again.</Typography.Text>,
      });

      throw new Error("ERR_USE_NEAR_MARKET_CONTRACT_INVALID_WALLET_CONNECTION");
    }
  };

  const ftTransferCall = async (prompt: Prompt) => {
    if (!marketContractValues) {
      return;
    }

    try {
      assertWalletConnection();

      setActions((prev) => ({
        ...prev,
        ftTransferCall: {
          ...prev.ftTransferCall,
          isLoading: true,
        },
      }));

      const amount = marketContractValues.fees.price.toString();
      const msg = JSON.stringify({ CreateOutcomeTokenArgs: { prompt: JSON.stringify(prompt) } });

      await FungibleTokenContract.ftTransferCall(
        walletStateContext.context.wallet!,
        marketContractValues.collateralToken.id!,
        {
          receiver_id: marketId,
          amount,
          msg,
        },
      );

      setActions((prev) => ({
        ...prev,
        ftTransferCall: {
          ...prev.ftTransferCall,
          isLoading: false,
          success: true,
        },
      }));

      toast.trigger({
        variant: "confirmation",
        withTimeout: true,
        title: "Your prompt was successfully submitted",
        children: (
          <Typography.Text>{`Transferred USDT ${currency.convert.toDecimalsPrecisionString(
            amount,
            marketContractValues.collateralToken.decimals!,
          )} to ${marketId}`}</Typography.Text>
        ),
      });

      fetchMarketContractValues();
    } catch (error) {
      if ((error as Error).message === ErrorCode.ERR_FungibleTokenContract_ftTransferCall) {
        toast.trigger({
          variant: "error",
          title: "Failed to make transfer call",
          children: <Typography.Text>You already transferred to this match.</Typography.Text>,
        });
      } else {
        toast.trigger({
          variant: "error",
          title: "Failed to make transfer call",
          children: (
            <Typography.Text>Check your internet connection, connect your wallet and try again.</Typography.Text>
          ),
        });
      }

      setActions((prev) => ({
        ...prev,
        ftTransferCall: {
          ...prev.ftTransferCall,
          isLoading: false,
          success: true,
        },
      }));
    }
  };

  const sell = async () => {
    try {
      assertWalletConnection();

      await PromptWarsMarketContract.sell(walletStateContext.context.wallet!, marketId);

      toast.trigger({
        variant: "confirmation",
        withTimeout: false,
        title: "Success",
        children: <Typography.Text>Check your new wallet balance.</Typography.Text>,
      });
    } catch {
      toast.trigger({
        variant: "error",
        withTimeout: true,
        title: "Failed to call sell method",
        children: (
          <Typography.Text>Check your internet connection, your NEAR wallet connection and try again.</Typography.Text>
        ),
      });
    }
  };

  const getOutcomeToken = async (args: GetOutcomeTokenArgs) => {
    try {
      if (!marketContract) {
        marketContract = await PromptWarsMarketContract.loadFromGuestConnection(marketId);
      }

      const outcomeToken = await marketContract.get_outcome_token(args);

      return outcomeToken;
    } catch (error) {
      console.log(error);
    }

    return undefined;
  };

  const create = async () => {
    setActions((prev) => ({
      ...prev,
      create: {
        isLoading: true,
      },
    }));

    try {
      const response = await fetch(routes.api.promptWars.create());

      if (!response.ok) {
        throw new Error("ERR_USE_NEAR_PROMPT_WARS_MARKET_CONTRACT_CREATE_FAILED");
      }

      router.push(routes.dashboard.promptWars.home());
    } catch (error) {
      console.log(error);

      toast.trigger({
        variant: "error",
        withTimeout: false,
        title: "Failed to create a new market",
        children: <Typography.Text>The server must have run out of funds. Please try again later.</Typography.Text>,
      });
    }

    setActions((prev) => ({
      ...prev,
      create: {
        isLoading: false,
      },
    }));
  };

  const props = {
    fetchMarketContractValues,
    marketContractValues,
    ftTransferCall,
    sell,
    getOutcomeToken,
    actions,
    marketId,
    create,
  };

  return (
    <NearPromptWarsMarketContractContext.Provider value={props}>
      {children}
    </NearPromptWarsMarketContractContext.Provider>
  );
};
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/providers/near/contracts/fungible-token/contract.ts#L117
import { Contract, WalletConnection } from "near-api-js";
import * as nearAPI from "near-api-js";
import { BN } from "bn.js";
import { Wallet } from "@near-wallet-selector/core";
import { FinalExecutionOutcome } from "near-api-js/lib/providers";

import near from "providers/near";
import { AccountId } from "../market/market.types";
import { ErrorCode } from "providers/near/error-codes";

import {
  FungibleTokenContractMethods,
  FungibleTokenContractValues,
  FtTransferCallArgs,
  FtBalanceOfArgs,
} from "./fungible-token.types";
import { CHANGE_METHODS, VIEW_METHODS } from "./constants";

export class FungibleTokenContract {
  values: FungibleTokenContractValues | undefined;

  contractAddress: AccountId;

  contract: Contract & FungibleTokenContractMethods;

  constructor(contractAddress: AccountId, contract: Contract & FungibleTokenContractMethods) {
    this.contract = contract;
    this.contractAddress = contractAddress;
  }

  static async loadFromGuestConnection(contractAddress: string) {
    const connection = await nearAPI.connect({
      keyStore: new nearAPI.keyStores.InMemoryKeyStore(),
      headers: {},
      ...near.getConfig(),
    });

    const account = await connection.account(near.getConfig().guestWalletId);
    const contractMethods = { viewMethods: VIEW_METHODS, changeMethods: CHANGE_METHODS };

    const contract = near.initContract<FungibleTokenContractMethods>(account, contractAddress, contractMethods);

    return new FungibleTokenContract(contractAddress, contract);
  }

  static async loadFromWalletConnection(connection: WalletConnection, contractAddress: string) {
    const account = await connection.account();
    const contractMethods = { viewMethods: VIEW_METHODS, changeMethods: CHANGE_METHODS };

    const contract = near.initContract<FungibleTokenContractMethods>(account, contractAddress, contractMethods);

    return new FungibleTokenContract(contractAddress, contract);
  }

  static async register(contractId: AccountId, accountId: AccountId) {
    console.log(`register ${accountId} on ${contractId}`);

    const connection = await near.getPrivateKeyConnection();
    const account = await connection.account(near.getConfig().serverWalletId);

    const methodName = "storage_deposit";

    const gas = new BN("300000000000000");
    const attachedDeposit = new BN(near.parseNearAmount("0.1") as string);

    const args = { account_id: accountId, registration_only: true };

    await account.functionCall({
      contractId,
      methodName,
      args,
      gas,
      attachedDeposit,
    });
  }

  static async staticFtTransferCall(contractId: AccountId, amount: string, accountId: AccountId) {
    console.log(`static transfer ${amount} to ${accountId} from ${contractId}`);

    const connection = await near.getPrivateKeyConnection();
    const account = await connection.account(near.getConfig().serverWalletId);

    const methodName = "ft_transfer";

    const gas = new BN("300000000000000");
    const attachedDeposit = new BN("1");

    const args: Omit<FtTransferCallArgs, "msg"> = { receiver_id: accountId, amount };

    await account.functionCall({
      contractId,
      methodName,
      args,
      gas,
      attachedDeposit,
    });
  }

  static async ftTransferCall(wallet: Wallet, contractAddress: AccountId, args: FtTransferCallArgs) {
    try {
      const gas = new BN("60000000000000");

      const response = await wallet.signAndSendTransaction({
        receiverId: contractAddress,
        actions: [
          {
            type: "FunctionCall",
            params: {
              methodName: "ft_transfer_call",
              args,
              gas: gas.toString(),
              deposit: "1",
            },
          },
        ],
      });

      const value = near.unwrapFinalExecutionOutcome(response as FinalExecutionOutcome);

      if (value === "0") {
        throw new Error("ERR_FungibleTokenContract_ftTransferCall: failed transfer");
      }
    } catch (error) {
      console.log(error);

      throw new Error(ErrorCode.ERR_FungibleTokenContract_ftTransferCall);
    }

    return "0.00";
  }

  async ftBalanceOf(args: FtBalanceOfArgs) {
    try {
      const result = await this.contract.ft_balance_of(args);

      return result;
    } catch (error) {
      console.log(error);
    }

    return "0.00";
  }

  async ftMetadata() {
    try {
      const result = await this.contract.ft_metadata();

      return result;
    } catch (error) {
      console.log(error);
    }

    return undefined;
  }
}
https://github.com/aufacicenta/pulsemarkets/blob/master/app/src/providers/near/contracts/fungible-token/contract.ts#L127-L137
import { Contract, WalletConnection } from "near-api-js";
import * as nearAPI from "near-api-js";
import { BN } from "bn.js";
import { Wallet } from "@near-wallet-selector/core";
import { FinalExecutionOutcome } from "near-api-js/lib/providers";

import near from "providers/near";
import { AccountId } from "../market/market.types";
import { ErrorCode } from "providers/near/error-codes";

import {
  FungibleTokenContractMethods,
  FungibleTokenContractValues,
  FtTransferCallArgs,
  FtBalanceOfArgs,
} from "./fungible-token.types";
import { CHANGE_METHODS, VIEW_METHODS } from "./constants";

export class FungibleTokenContract {
  values: FungibleTokenContractValues | undefined;

  contractAddress: AccountId;

  contract: Contract & FungibleTokenContractMethods;

  constructor(contractAddress: AccountId, contract: Contract & FungibleTokenContractMethods) {
    this.contract = contract;
    this.contractAddress = contractAddress;
  }

  static async loadFromGuestConnection(contractAddress: string) {
    const connection = await nearAPI.connect({
      keyStore: new nearAPI.keyStores.InMemoryKeyStore(),
      headers: {},
      ...near.getConfig(),
    });

    const account = await connection.account(near.getConfig().guestWalletId);
    const contractMethods = { viewMethods: VIEW_METHODS, changeMethods: CHANGE_METHODS };

    const contract = near.initContract<FungibleTokenContractMethods>(account, contractAddress, contractMethods);

    return new FungibleTokenContract(contractAddress, contract);
  }

  static async loadFromWalletConnection(connection: WalletConnection, contractAddress: string) {
    const account = await connection.account();
    const contractMethods = { viewMethods: VIEW_METHODS, changeMethods: CHANGE_METHODS };

    const contract = near.initContract<FungibleTokenContractMethods>(account, contractAddress, contractMethods);

    return new FungibleTokenContract(contractAddress, contract);
  }

  static async register(contractId: AccountId, accountId: AccountId) {
    console.log(`register ${accountId} on ${contractId}`);

    const connection = await near.getPrivateKeyConnection();
    const account = await connection.account(near.getConfig().serverWalletId);

    const methodName = "storage_deposit";

    const gas = new BN("300000000000000");
    const attachedDeposit = new BN(near.parseNearAmount("0.1") as string);

    const args = { account_id: accountId, registration_only: true };

    await account.functionCall({
      contractId,
      methodName,
      args,
      gas,
      attachedDeposit,
    });
  }

  static async staticFtTransferCall(contractId: AccountId, amount: string, accountId: AccountId) {
    console.log(`static transfer ${amount} to ${accountId} from ${contractId}`);

    const connection = await near.getPrivateKeyConnection();
    const account = await connection.account(near.getConfig().serverWalletId);

    const methodName = "ft_transfer";

    const gas = new BN("300000000000000");
    const attachedDeposit = new BN("1");

    const args: Omit<FtTransferCallArgs, "msg"> = { receiver_id: accountId, amount };

    await account.functionCall({
      contractId,
      methodName,
      args,
      gas,
      attachedDeposit,
    });
  }

  static async ftTransferCall(wallet: Wallet, contractAddress: AccountId, args: FtTransferCallArgs) {
    try {
      const gas = new BN("60000000000000");

      const response = await wallet.signAndSendTransaction({
        receiverId: contractAddress,
        actions: [
          {
            type: "FunctionCall",
            params: {
              methodName: "ft_transfer_call",
              args,
              gas: gas.toString(),
              deposit: "1",
            },
          },
        ],
      });

      const value = near.unwrapFinalExecutionOutcome(response as FinalExecutionOutcome);

      if (value === "0") {
        throw new Error("ERR_FungibleTokenContract_ftTransferCall: failed transfer");
      }
    } catch (error) {
      console.log(error);

      throw new Error(ErrorCode.ERR_FungibleTokenContract_ftTransferCall);
    }

    return "0.00";
  }

  async ftBalanceOf(args: FtBalanceOfArgs) {
    try {
      const result = await this.contract.ft_balance_of(args);

      return result;
    } catch (error) {
      console.log(error);
    }

    return "0.00";
  }

  async ftMetadata() {
    try {
      const result = await this.contract.ft_metadata();

      return result;
    } catch (error) {
      console.log(error);
    }

    return undefined;
  }
}
https://github.com/aufacicenta/pulsemarkets/blob/master/app/src/providers/currency/convert.ts#L19
import { DEFAULT_DECIMALS_PRECISION } from "./constants";

const padDecimals = (decimals: number) => Number("1".padEnd(decimals + 1, "0"));

const toUIntAmount = (amount: number | string, decimals: number) => {
  const uIntAmount =
    Number(amount) > 0
      ? (Number(amount) * padDecimals(decimals)).toString().replace(".0", "")
      : Number(amount)
          .toFixed(decimals + 1)
          .replace("0.", "");

  return uIntAmount.toString();
};

const fromUIntAmount = (amount: string | number, decimals: number) =>
  (Number(amount) / padDecimals(decimals)).toFixed(DEFAULT_DECIMALS_PRECISION);

const toDecimalsPrecisionString = (amount: string | number, decimals: number) =>
  (Number(amount) / padDecimals(decimals)).toFixed(DEFAULT_DECIMALS_PRECISION);

const toFormattedString = (amount: string | number, decimals: number = 2, currency: string = "USD") => {
  const formatter = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });

  return formatter.format(Number(amount));
};

export default {
  toUIntAmount,
  fromUIntAmount,
  toDecimalsPrecisionString,
  toFormattedString,
};