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
  • Storing image URIs
  • Storing player data
  • Setting values from the client side
  • Rendering images from a player's OutcomeToken
  1. NEAR Protocol Recipes

Link an Account to Image, Text and Time Metadata with On-Chain Storage

Create Rust LookupMap data structures that store image IPFS URIs, text content and timestamps linked to NEAR wallet addresses. Learn how to put & fetch these efficiently via the UI.

PreviousRender a Data Table from Contract VIEW MethodsNextCreate Time State Machines with Realtime Block Timestamp Comparisons

Last updated 1 year ago

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.

In the previous chapter:

we explain how to create new game contracts and how some of its storage is set upon initialization:

#[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
            },
        }
    }

Storing image URIs

This technique has been in the blockchain industry for a while now, instead of storing the entire image file object, store only an IPFS hash of the image, or its equivalent.

Prompt Wars has IPFS utilities that you may implement in your dApp to achieve similar results:

These IPFS helpers are then used to orchestrate a new game contract:

Storing player data

Once a game is created, a player may submit a text prompt. Follow this walkthrough to learn how to link data to a player, on chain.

When a player submits a prompt, we call ft_transfer_call on the corresponding NEP141 contract, in this case USDT . If the transfer succeeds, it will make a cross-contract call to the game contract:

Let's look at the payload structure:

#[derive(Serialize, Deserialize)]
pub struct CreateOutcomeTokenArgs {
    // the outcome value, in this case, the prompt submitted to the competition
    pub prompt: String,
}

#[derive(Serialize, Deserialize)]
pub enum Payload {
    CreateOutcomeTokenArgs(CreateOutcomeTokenArgs),
}

Setting values from the client side

Once a contract client interface is instantiated, we may call its CHANGE methods:

In this case

static async ftTransferCall(wallet: Wallet, contractAddress: AccountId, args: FtTransferCallArgs)

Will be called upon the click of the "Submit Prompt" button, passing down the payload as it is expected by the Rust contract:

const onSubmit = async (prompt: Prompt) => {
    if (marketContractValues.isOver) {
      toast.trigger({
        variant: "error",
        title: t("promptWars.marketisover.title"),
        children: <Typography.Text>{t("promptwars.marketisover.description")}</Typography.Text>,
      });

      return;
    }

    await ftTransferCall(prompt);
  };

The clue to this storage strategy is in the definition of

#[derive(BorshSerialize, BorshDeserialize, Serialize)]
pub struct OutcomeToken {
    // the account id of the outcome_token
    pub outcome_id: OutcomeId,
    // the outcome value, in this case, the prompt submitted to the competition
    pub prompt: String,
    // the outcome value, in this case, the prompt submitted to the competition
    pub output_img_uri: Option<String>,
    // store the result from the image comparison: percentage_diff or pixel_difference
    pub result: Option<OutcomeTokenResult>,
    // total supply of this outcome_token
    pub total_supply: WrappedBalance,
}

an OutcomeToken is our naming convention of a player's prompt data, the image URI that got rendered from its prompt and the collateral token balance relative to the player's NEP141 deposit:

Rendering images from a player's OutcomeToken

The OutcomeToken  implementation has VIEW methods useful for getting any player's prompt data:

Render a Data Table from Contract VIEW Methods
Project Setup
clone or navigate the repo
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/ui/pulse/prompt-wars/results-modal/ResultsModal.tsx#L136
import clsx from "clsx";
import { useEffect, useState } from "react";

import { Modal } from "ui/modal/Modal";
import { Typography } from "ui/typography/Typography";
import { Grid } from "ui/grid/Grid";
import { Card } from "ui/card/Card";
import ipfs from "providers/ipfs";
import { OutcomeId } from "providers/near/contracts/prompt-wars/prompt-wars.types";
import { useNearPromptWarsMarketContractContext } from "context/near/prompt-wars-market-contract/useNearPromptWarsMarketContractContext";
import { Icon } from "ui/icon/Icon";

import styles from "./ResultsModal.module.scss";
import { ResultsModalOutcomeToken, ResultsModalProps } from "./ResultsModal.types";

export const ResultsModal: React.FC<ResultsModalProps> = ({ onClose, className, marketContractValues }) => {
  const [outcomeToken, setOutcomeToken] = useState<ResultsModalOutcomeToken | undefined>();
  const [winnerOutcomeToken, setWinnerOutcomeToken] = useState<ResultsModalOutcomeToken | undefined>();

  const contract = useNearPromptWarsMarketContractContext();

  const { resolution, market, outcomeIds, isResolved } = marketContractValues;

  const getOutcomeToken = async (outcome_id: OutcomeId) => {
    const ot = await contract.getOutcomeToken({ outcome_id });

    if (!ot) {
      return;
    }

    setOutcomeToken({
      outcomeId: ot.outcome_id,
      outputImgUrl: ipfs.asHttpsURL(ot.output_img_uri!),
      prompt: JSON.parse(ot.prompt).value,
      negativePrompt: JSON.parse(ot.prompt).negative_prompt,
      result: ot.result!,
    });
  };

  const getWinnerOutcomeToken = async () => {
    const ot = await contract.getOutcomeToken({ outcome_id: resolution.result! });

    if (!ot) {
      return;
    }

    setWinnerOutcomeToken({
      outcomeId: ot.outcome_id,
      outputImgUrl: ipfs.asHttpsURL(ot.output_img_uri!),
      prompt: JSON.parse(ot.prompt).value,
      negativePrompt: JSON.parse(ot.prompt).negative_prompt,
      result: ot.result!,
    });
  };

  useEffect(() => {
    if (!isResolved) {
      getOutcomeToken(outcomeIds[0]);

      return;
    }

    getWinnerOutcomeToken();
    getOutcomeToken(resolution.result!);
  }, []);

  return (
    <Modal
      className={clsx(styles["results-modal"], className)}
      isOpened
      aria-labelledby="Prompt Wars Reveal Progress Modal Window"
      onClose={onClose}
      fullscreenVariant="default"
    >
      <Modal.Header onClose={onClose}>
        <Typography.Headline2 className={styles["results-modal__title"]} flat>
          Results <span>(closest to 0 wins)</span>
        </Typography.Headline2>
        <Typography.Text flat>
          Winner:{" "}
          <>
            {resolution?.result ? (
              <>
                {resolution?.result}, {winnerOutcomeToken?.result}
              </>
            ) : (
              "TBD"
            )}
          </>
        </Typography.Text>
      </Modal.Header>
      <Modal.Content>
        <Grid.Row>
          <Grid.Col lg={4} className={styles["results-modal__img-col"]}>
            <Card>
              <Card.Content className={styles["results-modal__img-col--content"]}>
                <img src={ipfs.asHttpsURL(market.image_uri)} alt="source" />
              </Card.Content>
            </Card>
          </Grid.Col>
          <Grid.Col lg={4} className={styles["results-modal__img-col"]}>
            <Card>
              <Card.Content className={styles["results-modal__img-col--content"]}>
                <div className={styles["results-modal__outcome-ids-list"]}>
                  {outcomeIds.map((outcomeId) => (
                    <div
                      key={outcomeId}
                      className={styles["results-modal__outcome-ids-list--item"]}
                      onClick={() => getOutcomeToken(outcomeId)}
                      onKeyPress={() => undefined}
                      role="button"
                      tabIndex={0}
                    >
                      <div className={styles["results-modal__outcome-ids-list--item-left"]}>
                        <Typography.Description
                          flat
                          className={clsx({
                            [styles["results-modal__outcome-ids-list--item-winner"]]: outcomeId === resolution.result,
                          })}
                        >
                          {outcomeId === resolution.result && <Icon name="icon-medal-first" />} {outcomeId}
                        </Typography.Description>
                      </div>
                      <div className={styles["results-modal__outcome-ids-list--item-right"]}>
                        <Icon name="icon-chevron-right" />
                      </div>
                    </div>
                  ))}
                </div>
              </Card.Content>
            </Card>
          </Grid.Col>
          <Grid.Col lg={4} className={styles["results-modal__img-col"]}>
            <Card>
              <Card.Content className={styles["results-modal__img-col--content"]}>
                <img src={outcomeToken?.outputImgUrl || "/shared/loading-spinner.gif"} alt="output" />
              </Card.Content>
            </Card>
          </Grid.Col>
        </Grid.Row>
      </Modal.Content>
      <Modal.Actions className={styles["results-modal__modal-actions"]}>
        <Grid.Row className={styles["results-modal__modal-actions--row"]}>
          <Grid.Col lg={3}>
            <Typography.Description>Account</Typography.Description>
            <Typography.Text className={styles["results-modal__modal-actions--text"]}>
              {outcomeToken?.outcomeId || "Loading"}
            </Typography.Text>
          </Grid.Col>
          <Grid.Col lg={3}>
            <Typography.Description>Prompt</Typography.Description>
            <Typography.Text className={styles["results-modal__modal-actions--text"]}>
              {outcomeToken?.prompt || "Loading"}
            </Typography.Text>
          </Grid.Col>
          <Grid.Col lg={3}>
            <Typography.Description>Negative Prompt</Typography.Description>
            <Typography.Text className={styles["results-modal__modal-actions--text"]}>
              {outcomeToken?.negativePrompt || "n/a"}
            </Typography.Text>
          </Grid.Col>
          <Grid.Col lg={3}>
            <Typography.Description>Result (closest to 0 wins)</Typography.Description>
            <Typography.Headline2 flat>{outcomeToken?.result || "Loading"}</Typography.Headline2>
          </Grid.Col>
        </Grid.Row>
      </Modal.Actions>
    </Modal>
  );
};
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/providers/ipfs/upload.ts#L48-L61
import path from "path";
import { HTTPClientExtraOptions } from "ipfs-http-client/dist/src/types";
import { CID } from "multiformats/cid";

import client from "./client";
import { ContentOptions, IpfsResponse } from "./ipfs.types";

function ensureIpfsUriPrefix(cidOrURI: CID) {
  let uri = cidOrURI.toString();

  if (!uri.startsWith("ipfs://")) {
    uri = `ipfs://${cidOrURI}`;
  }

  // Avoid the Nyan Cat bug (https://github.com/ipfs/go-ipfs/pull/7930)
  if (uri.startsWith("ipfs://ipfs/")) {
    uri = uri.replace("ipfs://ipfs/", "ipfs://");
  }

  return uri;
}

const getFileBasename = (options: ContentOptions) => {
  const filePath = options.path || "asset.bin";
  const basename = path.basename(filePath);

  return basename;
};

async function addFileToIPFS(
  content: Uint8Array,
  options: ContentOptions,
  ipfsOptions?: HTTPClientExtraOptions,
): Promise<IpfsResponse> {
  const basename = getFileBasename(options);

  const ipfsPath = `/pulsemarkets/${basename}`;

  const result = await client.add({ path: ipfsPath, content }, { hashAlg: "sha2-256", ...ipfsOptions });

  return {
    ...result,
    path: `${result.cid.toString()}/${basename}`,
    uri: `${ensureIpfsUriPrefix(result.cid)}/${basename}`,
  };
}

const upload = async (content: Buffer, name: string): Promise<IpfsResponse | null> => {
  try {
    const result = await addFileToIPFS(content, { path: name });

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

    throw new Error("providers/ipfs/upload: failed to upload file");
  }
};

export const getFileAsIPFSUrl = async (url: string, headers?: HeadersInit): Promise<string> => {
  try {
    const response = await fetch(url, {
      method: "GET",
      headers,
    });

    const blob = await response.arrayBuffer();
    const fileName = url.split("/").pop();

    const ipfsResponse = await upload(Buffer.from(blob), fileName!);

    return ipfsResponse?.path || "";
  } catch (error) {
    console.log(error);

    throw new Error("providers/ipfs/getfileAsIPFsUrl: invalid file response");
  }
};

export default upload;
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/pages/api/prompt-wars/create.ts#L22
import { v4 as uuidv4 } from "uuid";
import { NextApiRequest, NextApiResponse } from "next";

import logger from "providers/logger";
import { DeployPromptWarsMarketContractArgs } from "providers/near/contracts/prompt-wars-market-factory/prompt-wars-market-factory.types";
import pulse from "providers/pulse";
import { CollateralToken, Management, MarketData } from "providers/near/contracts/prompt-wars/prompt-wars.types";
import near from "providers/near";
import { PromptWarsMarketFactory } from "providers/near/contracts/prompt-wars-market-factory/contract";
import { PromptWarsMarketContract } from "providers/near/contracts/prompt-wars/contract";
import ipfs from "providers/ipfs";
import { routes } from "hooks/useRoutes/useRoutes";

export default async function Fn(_request: NextApiRequest, response: NextApiResponse) {
  try {
    const latestMarketId = await pulse.promptWars.getLatestMarketId();

    if (await pulse.promptWars.isMarketActive(latestMarketId!)) {
      throw new Error("ERR_LATEST_MARKET_IS_STILL_ACTIVE");
    }

    const image_uri = await ipfs.getFileAsIPFSUrl("https://source.unsplash.com/random/512x512");

    const market: MarketData = {
      image_uri,
      //   Set to 0, it will be set in the contract initialization
      starts_at: 0,
      //   Set to 0, it will be set in the contract initialization
      ends_at: 0,
    };

    const management: Management = {
      dao_account_id: near.getConfig().marketDaoAccountId,
      market_creator_account_id: near.getConfig().serverWalletId,
      //   Set to 0, it will be set in the contract initialization
      self_destruct_window: 0,
      //   Set to 0, it will be set in the contract initialization
      buy_sell_threshold: 0,
    };

    const { accountId, decimals } = pulse.getDefaultCollateralToken();

    const collateral_token: CollateralToken = {
      id: accountId,
      decimals,
      balance: 0,
      fee_balance: 0,
    };

    const promptWarsMarketArgs: DeployPromptWarsMarketContractArgs = {
      market,
      management,
      collateral_token,
    };

    const id = `pw-${uuidv4().slice(0, 4)}`;

    await PromptWarsMarketFactory.createMarket(id, promptWarsMarketArgs);

    logger.info({ promptWarsMarketArgs, id });

    const marketId = `${id}.${near.getConfig().factoryWalletId}`;

    const marketContract = await PromptWarsMarketContract.loadFromGuestConnection(marketId);
    const marketData = await marketContract.get_market_data();
    const resolution = await marketContract.get_resolution_data();

    let ms = marketData.ends_at - marketData.starts_at;
    const revealEndpoint = routes.api.promptWars.reveal();

    logger.info(`setting timeout to call the reveal API endpoint ${revealEndpoint} for market ${marketId} in ${ms} ms`);
    setTimeout(async () => {
      try {
        logger.info(`calling the reveal API endpoint ${revealEndpoint} for market ${marketId}`);
        await fetch(revealEndpoint);
      } catch (error) {
        logger.error(error);
      }
    }, ms);

    ms = resolution.reveal_window - marketData.starts_at;
    const resolveEndpoint = routes.api.promptWars.resolve();

    logger.info(
      `setting timeout to call the resolution API endpoint ${resolveEndpoint} for market ${marketId} in ${ms} ms`,
    );
    setTimeout(async () => {
      try {
        logger.info(`calling resolution API endpoint ${resolveEndpoint} for market ${marketId}`);
        await fetch(resolveEndpoint);
      } catch (error) {
        logger.error(error);
      }
    }, ms);

    response.status(200).json({ promptWarsMarketArgs, id });
  } catch (error) {
    logger.error(error);

    response.status(500).json({ error: (error as Error).message });
  }
}
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/contracts/prompt-wars/src/outcome_token.rs
use near_sdk::{env, log};
use shared::OutcomeId;

use crate::{
    storage::{OutcomeToken, WrappedBalance},
    OutcomeTokenResult,
};

impl Default for OutcomeToken {
    fn default() -> Self {
        panic!("OutcomeToken should be initialized before usage")
    }
}

impl OutcomeToken {
    pub fn new(outcome_id: &OutcomeId, prompt: String, initial_supply: WrappedBalance) -> Self {
        Self {
            outcome_id: outcome_id.clone(),
            prompt,
            result: None,
            output_img_uri: None,
            total_supply: initial_supply,
        }
    }

    pub fn burn(&mut self) {
        self.total_supply -= self.total_supply();

        log!(
            "Burned {} of outcome_id [{}]. total_supply: {}",
            self.total_supply(),
            self.outcome_id,
            self.total_supply()
        );
    }

    pub fn set_result(&mut self, result: OutcomeTokenResult) {
        if let Some(_r) = self.result {
            env::panic_str("ERR_SET_RESULT_ALREADY_SET");
        }

        self.result = Some(result);
    }

    pub fn set_output_img_uri(&mut self, output_img_uri: String) {
        if let Some(_r) = &self.output_img_uri {
            env::panic_str("ERR_SET_OUTPUT_IMG_URI_ALREADY_SET");
        }

        self.output_img_uri = Some(output_img_uri);
    }

    pub fn get_balance_of(&self) -> WrappedBalance {
        self.total_supply
    }

    pub fn total_supply(&self) -> WrappedBalance {
        self.total_supply
    }

    pub fn outcome_id(&self) -> OutcomeId {
        self.outcome_id.clone()
    }

    pub fn get_prompt(&self) -> String {
        self.prompt.clone()
    }

    pub fn get_result(&self) -> Option<OutcomeTokenResult> {
        self.result
    }

    pub fn get_output_img_uri(&self) -> Option<String> {
        self.output_img_uri.clone()
    }
}
https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/providers/near/contracts/fungible-token/contract.ts#L98-L125
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/prompt-wars/contracts/prompt-wars/src/ft_receiver.rs#L30