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.

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.

In the previous chapter:

Render a Data Table from Contract VIEW Methods

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:

https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/providers/ipfs/upload.ts#L48-L61
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");
  }
};

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

https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/pages/api/prompt-wars/create.ts#L22
    const image_uri = await ipfs.getFileAsIPFSUrl("https://source.unsplash.com/random/512x512");

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:

https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/contracts/prompt-wars/src/ft_receiver.rs#L30
                self.create_outcome_token(sender_id, amount, payload)

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:

https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/providers/near/contracts/fungible-token/contract.ts#L98-L125

  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);

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:

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()
    }
}

Rendering images from a player's OutcomeToken

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

https://github.com/aufacicenta/pulsemarkets/blob/prompt-wars/app/src/ui/pulse/prompt-wars/results-modal/ResultsModal.tsx#L136
                <img src={outcomeToken?.outputImgUrl || "/shared/loading-spinner.gif"} alt="output" />

Last updated