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.

In the previous chapter:
Render a Data Table from Contract VIEW Methodswe 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:
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:
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:
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:
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:
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
OutcomeToken
The OutcomeToken
implementation has VIEW methods useful for getting any player's prompt data:
<img src={outcomeToken?.outputImgUrl || "/shared/loading-spinner.gif"} alt="output" />
Last updated