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.
Last updated
Learn to use ft_transfer_call function calls. This method needs a bit of Rust logic to make it work with the target contract.
Last updated
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 Project Setup instructions to start the project in your local machine and try the recipes yourself. You can also clone or navigate the repo 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.
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.
Read through NEAR's Fungible Token docs to learn more about this pattern.
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.
ft_on_transfer
msg
PayloadThe 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.
ft_transfer_call
from the client-sideThis 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
:
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
.
Note that the response of wallet.signAndSendTransaction needs to be parsed because it is formatted as hex. Check this unwrapping function helper here.
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:
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.
Luckily for you, Prompt Wars needed to display formatted amounts and we developed some helpers that may suit your dApp as well:
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,
};
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()
);
}
}
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>
);
};
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;
}
}
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;
}
}