Skip to content

Commit

Permalink
Increase fallback coverage for mint character (#119)
Browse files Browse the repository at this point in the history
### Description
Review frontend error handling for the mint flow and ensure the frontend
does not get "stuck" when minting a character

### Validation and error handling
The mint flow starts with the "Create character" input form, which takes
a string as input (to pick a character name) and performs the following
validation (showing errors when present):
1. Name adheres to the guidelines set on contract (special characters,
length requirements, etc)
2. Name is unique
3. User has enough IST available to mint

If no validation errors are present, the `mint` button is enabled.
Pressing mint will form the mint proposal based on the input and trigger
Agoric's `addOffer` method, which also accepts a callback set up to
track offer updates.

Should anything error out in the process of forming and executing
`addOffer` the error will be caught and a generic error will be
displayed to the user.

If addOffer is triggered correctly, the following `offerStatus` updates
are handled:

1. `seated`: indicates the offer was successfully parsed by the
contract? (I couldn't find documentation around this, so this definition
is from experience). We start a timer which will display an error to the
user in case no further offer updates are received within 30s (typically
the mint offer returns the "success" status within 10s of it being
seated)
2. `error`: indicates an error occurred while minting. A generic error
is displayed prompting the user to try again
3. `accepted`: indicates mint call was successful. We clear the timer
set to error if the call takes too long. We do not yet give the user
confirmation, as we want to wait until the new character is accessible
from the user's wallet

Finally, we have a useEffect in the create-character component that
listens for updates in the user's wallet and shows confirmation to the
user once the newly minted character is correctly parsed by KREAd's
frontend
  • Loading branch information
carlos-kryha authored Mar 6, 2024
1 parent e060ce3 commit d6982ec
Show file tree
Hide file tree
Showing 11 changed files with 1,496 additions and 1,295 deletions.
2,691 changes: 1,447 additions & 1,244 deletions agoric/yarn.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"react-compound-slider": "^3.4.0",
"react-dom": "^18.1.0",
"react-error-boundary": "^3.1.4",
"react-helmet": "^6.1.0",
"react-helmet-async": "^2.0.4",
"react-hook-form": "^7.31.3",
"react-konva": "^18.2.10",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/assets/text/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export const error = {
goHome: "go home",
downloadFailed: "an error occurred while downloading the character",
mint: {
general: "An error occurred while minting the character, please try again later",
invalidName: "Name is already taken",
nameTaken: "Name taken",
title: "Mint failed",
insufficientFunds: (ist: bigint) => `Insufficient funds (current balance: ${uISTToIST(Number(ist))} IST)`,
callStuck: "The call seems to be taking too long, please try again later",
},
youHaveNotEquipped: "Oops..you have not equipped your item!",
categoryAlreadyEquipped: {
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const MINTING_COST_USD = MINTING_COST / Number("1".padEnd(MONEY_DECIMALS
export const SUCCESSFUL_MINT_REPONSE_MSG = "Character mint successful, use attached public facet to purchase" as const;
export const SELL_CHARACTER_DESCRIPTION = "Sell Character in KREAd marketplace" as const;
export const SELL_ITEM_DESCRIPTION = "Sell Item in KREAd marketplace" as const;
export const NO_SMART_WALLET_ERROR = 'no smart wallet';
export const NO_SMART_WALLET_ERROR = "no smart wallet";
export const MINT_CHARACTER_FLOW_STEPS = 3 as const;
export const BUY_FLOW_STEPS = 2 as const;
export const SELL_FLOW_STEPS = 3 as const;
Expand Down Expand Up @@ -171,10 +171,7 @@ export const INVENTORY_CALL_FETCH_DELAY = 10000 as const;

export const PINATA_GATEWAY = import.meta.env.VITE_PINATA_GATEWAY || "https://pink-defensive-jay-557.mypinata.cloud";

// Contract errors

export const KREAD_CONTRACT_ERRORS = {
invalidName: "Offer error Error: (a string)",
};
export const PLATFORM_RATE = 0.03;
export const ROYALTY_RATE = 0.1;

export const MINT_CALL_TIMEOUT = 30000;
7 changes: 3 additions & 4 deletions frontend/src/context/agoric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AgoricKeplrConnectionErrors as Errors, makeAgoricWalletConnection } fro
import { makeAsyncIterableFromNotifier as iterateNotifier } from "@agoric/notifier";
import { CHARACTER_IDENTIFIER, IST_IDENTIFIER, ITEM_IDENTIFIER, KREAD_IDENTIFIER, NO_SMART_WALLET_ERROR } from "../constants";
import { fetchChainInfo } from "./util";
import { AgoricChainStoragePathKind as Kind, ChainStorageWatcher, makeAgoricChainStorageWatcher } from "@agoric/rpc";
import { type ChainStorageWatcher, AgoricChainStoragePathKind as Kind, makeAgoricChainStorageWatcher } from "@agoric/rpc";
import { useNetworkConfig } from "../hooks/useNetwork";

const initialState: AgoricState = {
Expand Down Expand Up @@ -189,13 +189,12 @@ export const AgoricStateProvider = (props: ProviderProps): React.ReactElement =>
if (e.message === NO_SMART_WALLET_ERROR) {
dispatch({ type: "UPDATE_STATUS", payload: { walletProvisioned: false }});
} else {
console.error('Error in wallet backend', e);
console.error("Error in wallet backend", e);
}
return;
};

try {

const { rpc, chainName } = await fetchChainInfo(network);
chainStorageWatcher = makeAgoricChainStorageWatcher(rpc, chainName, backendError);
dispatch({ type: "SET_CHAIN_STORAGE_WATCHER", payload: chainStorageWatcher });
Expand All @@ -217,7 +216,7 @@ export const AgoricStateProvider = (props: ProviderProps): React.ReactElement =>
return () => {
setIsCancelled(true);
};
}, [network, isCancelled, state.chainStorageWatcher]);
}, [network, isCancelled, state.chainStorageWatcher, state.status.walletProvisioned]);

return (
<Context.Provider value={state}>
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/context/wallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { IST_IDENTIFIER, SELL_CHARACTER_INVITATION, SELL_ITEM_INVITATION } from
import { watchExistingCharacterPaths, watchWalletVstorage } from "../service/storage-node/watch-general";
import { Item, OfferProposal } from "../interfaces";
import { makeAsyncIterableFromNotifier as iterateNotifier } from "@agoric/notifier";
import { AgoricChainStoragePathKind as Kind } from "@agoric/rpc";

export interface WalletContext {
token: any;
Expand Down Expand Up @@ -42,20 +41,17 @@ export const WalletContextProvider = (props: ProviderProps): React.ReactElement

useEffect(() => {
if(!agoric.status.walletProvisioned) {
console.warn("Smartwallet is not provisioned");
return;
}

const updateStateNonVbank = async (purses: any) => {
console.count("💾 LOADING PURSE CHANGE 💾");

// Load Purses
// TODO: Read IST balance
const newCharacterPurses = purses.filter(({ brand }: any) => brand === tokenInfo.character.brand);
const newItemPurses = purses.filter(({ brand }: any) => brand === tokenInfo.item.brand);
const characterWallet = newCharacterPurses[newCharacterPurses.length - 1]?.balance.value.payload.map((i: any) => i[0]);
// FIXME: this is not going to work when a users has more than 1 item that is the same (then it will be 2n)
// Consider creating an array that fills an array n amount of times based onthe amount the user owns

let itemWallet = newItemPurses[newItemPurses.length - 1]?.balance.value.payload
.map((i: any) => {
const itemArray: any = [];
Expand Down Expand Up @@ -116,7 +112,7 @@ export const WalletContextProvider = (props: ProviderProps): React.ReactElement
}
};
watchVBankAssets().catch((err: Error) => {
console.error("got status watch err", err);
console.warn("got status watch err", err);
});

if (!walletState.fetched && chainStorageWatcher) {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/pages/connect-wallet/connect-wallet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC, useRef, useState } from "react";
import React, { FC, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeplerIcon, text } from "../../assets";
import { breakpoints, color } from "../../design";
Expand Down Expand Up @@ -44,6 +44,10 @@ export const ConnectWallet: FC = () => {
const [showWidget, setShowWidget] = useState(false);
const [showToast, setShowToast] = useState(false);

useEffect(()=>{
if (service.status.walletProvisioned) navigate(routes.character);
},[service.status.walletProvisioned, navigate]);

const toggleWidget = () => {
setShowWidget(!showWidget);
};
Expand All @@ -60,7 +64,6 @@ export const ConnectWallet: FC = () => {
};

if (!service.walletConnection.address) return <LoadingPage spinner={false} />;
if (service.status.walletProvisioned) navigate(routes.character);

return (
<>
Expand Down
42 changes: 30 additions & 12 deletions frontend/src/pages/create-character/create-character.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { FC, useEffect, useMemo, useState } from "react";
import { ElephiaCitizen, text } from "../../assets";
import { ErrorView, FadeInOut, FormHeader, LoadingPage, NotificationDetail, Overlay } from "../../components";
import { PageContainer } from "../../components/page-container";
import { MINTING_COST, MINT_CHARACTER_FLOW_STEPS, WALLET_INTERACTION_STEP } from "../../constants";
import { MINTING_COST, MINT_CALL_TIMEOUT, MINT_CHARACTER_FLOW_STEPS, WALLET_INTERACTION_STEP } from "../../constants";
import { useIsMobile, useViewport } from "../../hooks";
import { Character, CharacterCreation, MakeOfferCallback } from "../../interfaces";
import { routes } from "../../navigation";
Expand All @@ -15,24 +15,27 @@ import { breakpoints } from "../../design";
import { useUserState } from "../../context/user";
import { useWalletState } from "../../context/wallet";
import { NotificationWrapper } from "../../components/notification-detail/styles";
import { useNavigate } from "react-router-dom";

export const CreateCharacter: FC = () => {
const navigate = useNavigate();
const createCharacter = useCreateCharacter();
const { width, height } = useViewport();
const [currentStep, setCurrentStep] = useState<number>(0);
const [mintedCharacter, setMintedCharacter] = useState<Character>();
const [error, setError] = useState<string>();
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string>(text.error.mint.general);
const [characterData, setCharacterData] = useState<{ name: string }>({
name: "",
});
const { characters, fetched } = useUserState();
const isLoadingCharacters = !fetched;
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isOfferAccepted, setIsOfferAccepted] = useState<boolean>(false);
const [timeoutHandler, setTimeoutHandler] = useState<NodeJS.Timeout>();
const mobile = useIsMobile(breakpoints.desktop);
const { ist } = useWalletState();

const notEnoughIST = useMemo(() => {
if (ist < MINTING_COST || !ist) {
return true;
Expand All @@ -43,34 +46,46 @@ export const CreateCharacter: FC = () => {
useEffect(() => {
if (characters.map((c: any) => c.nft.name).includes(characterData.name)) {
setIsOfferAccepted(true);
clearTimeout(timeoutHandler);
const [newCharacter] = characters.filter((c: any) => c.nft.name === characterData.name);
setMintedCharacter(newCharacter.nft);
setIsLoading(false);
}
}, [characters, characterData, notEnoughIST]);
}, [characters, characterData, notEnoughIST, timeoutHandler]);

const changeStep = async (step: number): Promise<void> => {
setCurrentStep(step);
};

const errorCallback = (error: string) => {
setError(error);
console.error(error);
setShowToast(true);
};

const handleResult: MakeOfferCallback = {
error: errorCallback,
accepted: () => {
console.info("MintCharacter call settled");
clearTimeout(timeoutHandler);
},
seated: () => {
const mintTimeout = setTimeout(() => {
setError(text.error.mint.callStuck);
setShowToast(true);
}, MINT_CALL_TIMEOUT);
setTimeoutHandler(mintTimeout);
}
};

const sendOfferHandler = async (): Promise<void> => {
setIsLoading(true);
await createCharacter.mutateAsync({
name: characterData.name,
callback: handleResult,
});
try {
await createCharacter.mutateAsync({
name: characterData.name,
callback: handleResult,
});
} catch(e) {
setShowToast(true);
}
};

const setData = async (data: CharacterCreation): Promise<void> => {
Expand Down Expand Up @@ -113,8 +128,11 @@ export const CreateCharacter: FC = () => {
<NotificationWrapper showNotification={showToast}>
<NotificationDetail
title={text.error.mint.title}
info={text.error.mint.invalidName}
closeToast={() => setShowToast(false)}
info={error}
closeToast={() => {
setShowToast(false);
navigate(routes.character);
}}
isError
/>
</NotificationWrapper>
Expand Down
1 change: 0 additions & 1 deletion frontend/src/pages/create-character/payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const Payment: FC<PaymentProps> = ({ submit, sendOfferHandler, isOfferAcc
const [disable, setDisable] = useState(false);
const sendOfferToWallet = async () => {
setDisable(true);
console.info("SENDING OFFER TO WALLET");
await sendOfferHandler();
setSendOffer(true);
};
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/util/contract-callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ export const formOfferResultCallback =
break;
}
case OFFER_STATUS.seated: {
console.log("Offer seated");
console.info("Offer seated", JSON.stringify(data));
if(callback.seated) callback.seated();
break;
}
}
if (callback.setIsLoading) callback.setIsLoading(false);
if(callback.settled) callback.settled();
if (callback.settled) callback.settled();
};
19 changes: 2 additions & 17 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8446,7 +8446,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"

prop-types@^15.5.8, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.5.8, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
Expand Down Expand Up @@ -8588,7 +8588,7 @@ react-error-boundary@^3.1.4:
dependencies:
"@babel/runtime" "^7.12.5"

react-fast-compare@^3.1.1, react-fast-compare@^3.2.2:
react-fast-compare@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
Expand All @@ -8602,16 +8602,6 @@ react-helmet-async@^2.0.4:
react-fast-compare "^3.2.2"
shallowequal "^1.1.0"

react-helmet@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"
integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==
dependencies:
object-assign "^4.1.1"
prop-types "^15.7.2"
react-fast-compare "^3.1.1"
react-side-effect "^2.1.0"

react-hook-form@^7.31.3:
version "7.47.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.47.0.tgz#a42f07266bd297ddf1f914f08f4b5f9783262f31"
Expand Down Expand Up @@ -8688,11 +8678,6 @@ [email protected]:
dependencies:
"@remix-run/router" "1.10.0"

react-side-effect@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"
integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==

react-vis@^1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/react-vis/-/react-vis-1.12.1.tgz#2020c6025ceb10eace53d2366a6b8e9d90a47c54"
Expand Down

0 comments on commit d6982ec

Please sign in to comment.