Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Contract Spec: add download Wasm button + handle network error for better UX #1267

Open
wants to merge 5 commits into
base: sc-contract-spec
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export const ContractInfo = ({
<ContractSpec
wasmHash={infoData.wasm || ""}
contractId={infoData.account || ""}
networkId={network.id}
networkPassphrase={network.passphrase}
rpcUrl={network.rpcUrl}
isActive={activeTab === "contract-contract-spec"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
import { Alert, Text, Loader } from "@stellar/design-system";
import { useCallback, useEffect } from "react";
import { Alert, Text, Loader, Button, Icon } from "@stellar/design-system";
import { useQueryClient } from "@tanstack/react-query";

import { CodeEditor } from "@/components/CodeEditor";
import { Box } from "@/components/layout/Box";
import { ErrorText } from "@/components/ErrorText";

import { useWasmFromRpc } from "@/query/useWasmFromRpc";
import { useSEContractWasmBinary } from "@/query/external/useSEContractWasmBinary";
import { useIsXdrInit } from "@/hooks/useIsXdrInit";

import * as StellarXdr from "@/helpers/StellarXdr";
import { prettifyJsonString } from "@/helpers/prettifyJsonString";
import { useIsXdrInit } from "@/hooks/useIsXdrInit";
import { delayedAction } from "@/helpers/delayedAction";
import { downloadFile } from "@/helpers/downloadFile";

import { NetworkType } from "@/types/types";

export const ContractSpec = ({
contractId,
networkId,
networkPassphrase,
rpcUrl,
wasmHash,
isActive,
}: {
contractId: string;
networkId: NetworkType;
networkPassphrase: string;
rpcUrl: string;
wasmHash: string;
isActive: boolean;
}) => {
const isXdrInit = useIsXdrInit();
const queryClient = useQueryClient();

const {
data: wasmData,
Expand All @@ -37,6 +48,41 @@ export const ContractSpec = ({
isActive: Boolean(isActive && rpcUrl && wasmHash),
});

const {
data: wasmBinary,
error: wasmBinaryError,
isLoading: isWasmBinaryLoading,
isFetching: isWasmBinaryFetching,
refetch: fetchWasmBinary,
} = useSEContractWasmBinary({ wasmHash, networkId });

const resetWasmBlob = useCallback(() => {
queryClient.resetQueries({
queryKey: ["useSEContractWasmBinary", networkId, wasmHash],
});
}, [networkId, queryClient, wasmHash]);

useEffect(() => {
Copy link
Contributor

@jeesunikim jeesunikim Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do this onClick instead of lifecycle? like we already do with downloadFile in onClick?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The click triggers React Query to download the wasm binary, so we need to do something after this step happens. The other option would be to put this inside the React Query, but that would limit the usage of that query to just this.

I'm not a huge fan of this approach, but I couldn't come up with something better. I'm open to suggestions! 🙏

if (wasmBinary) {
downloadFile({
value: wasmBinary,
fileType: "application/octet-stream",
fileName: wasmHash,
fileExtension: "wasm",
});
}
}, [wasmBinary, wasmHash]);

useEffect(() => {
if (wasmBinaryError) {
// Automatically clear error message after 5 sec
delayedAction({
action: resetWasmBlob,
delay: 5000,
});
}
}, [resetWasmBlob, wasmBinaryError]);
Comment on lines +76 to +84
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find a way to clear this on specific events (unmount, inActive, etc.), so I opted to clear the message after 5 seconds. The UX seemed okay to me.


if (!wasmHash) {
return (
<Text as="div" size="sm">
Expand All @@ -63,7 +109,26 @@ export const ContractSpec = ({
}

if (wasmError) {
return <ErrorText errorMessage={wasmError.toString()} size="sm" />;
const errorString = wasmError.toString();
let networkMessage = null;

if (errorString.toLowerCase().includes("network error")) {
networkMessage = (
<Alert variant="warning" placement="inline" title="Attention">
There may be an issue with the RPC server. You can change it in the
network settings in the upper right corner.
</Alert>
);
}

return (
<Box gap="lg">
<>
<ErrorText errorMessage={errorString} size="sm" />
{networkMessage}
</>
</Box>
);
}

const formatSpec = () => {
Expand All @@ -90,6 +155,43 @@ export const ContractSpec = ({
};

return (
<CodeEditor title="Contract Spec" value={formatSpec()} language="json" />
<Box gap="lg">
<CodeEditor
title="Contract Spec"
value={formatSpec()}
language="json"
fileName={`${wasmHash}-contract-spec`}
/>

<Box gap="xs" direction="column" align="end">
<Button
variant="tertiary"
size="sm"
icon={<Icon.Download01 />}
iconPosition="left"
onClick={(e) => {
e.preventDefault();

if (wasmBinaryError) {
resetWasmBlob();
}

delayedAction({
action: fetchWasmBinary,
delay: wasmBinaryError ? 500 : 0,
});
}}
isLoading={isWasmBinaryLoading || isWasmBinaryFetching}
>
Download WASM
</Button>

<>
{wasmBinaryError ? (
<ErrorText errorMessage={wasmBinaryError.toString()} size="sm" />
) : null}
</>
</Box>
</Box>
);
};
37 changes: 35 additions & 2 deletions src/components/CodeEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { useEffect, useRef, useState } from "react";
import { Button, CopyText, Icon } from "@stellar/design-system";
import MonacoEditor, { useMonaco } from "@monaco-editor/react";

import { useStore } from "@/store/useStore";
import { Box } from "@/components/layout/Box";
import { downloadFile } from "@/helpers/downloadFile";

import "./styles.scss";

type CodeEditorProps = { title: string; value: string; language: "json" };
type CodeEditorProps = {
title: string;
value: string;
language: "json";
fileName?: string;
};

export const CodeEditor = ({ title, value, language }: CodeEditorProps) => {
export const CodeEditor = ({
title,
value,
language,
fileName,
}: CodeEditorProps) => {
const { theme } = useStore();
const monaco = useMonaco();
const headerEl = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -37,6 +49,27 @@ export const CodeEditor = ({ title, value, language }: CodeEditorProps) => {

{/* Actions */}
<Box gap="xs" direction="row" align="center" justify="end">
<>
{fileName ? (
<Button
variant="tertiary"
size="sm"
icon={<Icon.Download01 />}
title="Download"
onClick={(e) => {
e.preventDefault();

downloadFile({
value,
fileType: "application/json",
fileName,
fileExtension: language,
});
}}
></Button>
) : null}
</>

<CopyText textToCopy={value}>
<Button
variant="tertiary"
Expand Down
2 changes: 1 addition & 1 deletion src/components/DataTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ export const DataTable = <T extends AnyObject>({
<Button
variant="tertiary"
size="sm"
icon={<Icon.AlignBottom01 />}
icon={<Icon.Download01 />}
iconPosition="left"
onClick={handleExportToCsv}
>
Expand Down
24 changes: 24 additions & 0 deletions src/helpers/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const downloadFile = ({
value,
fileType,
fileName,
fileExtension,
}: {
value: string | ArrayBuffer;
fileType: string;
fileName: string;
fileExtension: string;
}) => {
// Create blob
const blob = new Blob([value], {
type: fileType,
});

// Create a link element and trigger a download
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.setAttribute("download", `${fileName}.${fileExtension}`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
17 changes: 7 additions & 10 deletions src/helpers/exportJsonToCsvFile.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { unparse } from "papaparse";
import { downloadFile } from "@/helpers/downloadFile";
import { AnyObject } from "@/types/types";

export const exportJsonToCsvFile = (
jsonArray: AnyObject[],
fileName: string,
) => {
// Create CSV blob
const csvBlob = new Blob([unparse(jsonArray)], { type: "text/csv" });

// Create a link element and trigger a download
const link = document.createElement("a");
link.href = URL.createObjectURL(csvBlob);
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
downloadFile({
value: unparse(jsonArray),
fileExtension: "csv",
fileName,
fileType: "text/csv",
});
};
8 changes: 4 additions & 4 deletions src/query/external/useSEContractInfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { STELLAR_EXPERT_API } from "@/constants/settings";
import { getStellarExpertNetwork } from "@/helpers/getStellarExpertNetwork";
import { ContractInfoApiResponse, NetworkType } from "@/types/types";

/**
Expand All @@ -15,13 +16,12 @@ export const useSEContractInfo = ({
const query = useQuery<ContractInfoApiResponse>({
queryKey: ["useSEContractInfo", networkId, contractId],
queryFn: async () => {
// Not supported networks
if (["futurenet", "custom"].includes(networkId)) {
const network = getStellarExpertNetwork(networkId);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small refactor to use a helper since we have this check in multiple places.


if (!network) {
return null;
}

const network = networkId === "mainnet" ? "public" : "testnet";

try {
const response = await fetch(
`${STELLAR_EXPERT_API}/${network}/contract/${contractId}`,
Expand Down
8 changes: 4 additions & 4 deletions src/query/external/useSEContractVersionHistory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { STELLAR_EXPERT_API } from "@/constants/settings";
import { getStellarExpertNetwork } from "@/helpers/getStellarExpertNetwork";
import { ContractVersionHistoryResponseItem, NetworkType } from "@/types/types";

/**
Expand All @@ -17,13 +18,12 @@ export const useSEContractVersionHistory = ({
const query = useQuery<ContractVersionHistoryResponseItem[]>({
queryKey: ["useSEContractVersionHistory", networkId, contractId],
queryFn: async () => {
// Not supported networks
if (["futurenet", "custom"].includes(networkId)) {
const network = getStellarExpertNetwork(networkId);

if (!network) {
return null;
}

const network = networkId === "mainnet" ? "public" : "testnet";

try {
const response = await fetch(
`${STELLAR_EXPERT_API}/${network}/contract/${contractId}/version`,
Expand Down
47 changes: 47 additions & 0 deletions src/query/external/useSEContractWasmBinary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { STELLAR_EXPERT_API } from "@/constants/settings";
import { getStellarExpertNetwork } from "@/helpers/getStellarExpertNetwork";
import { NetworkType } from "@/types/types";

/**
* StellarExpert API to get smart contract wasm binary
*/
export const useSEContractWasmBinary = ({
networkId,
wasmHash,
}: {
networkId: NetworkType;
wasmHash: string;
}) => {
const query = useQuery<ArrayBuffer | null>({
queryKey: ["useSEContractWasmBinary", networkId, wasmHash],
queryFn: async () => {
const network = getStellarExpertNetwork(networkId);

if (!network) {
return null;
}

try {
const response = await fetch(
`${STELLAR_EXPERT_API}/${network}/wasm/${wasmHash}`,
);

if (response.status === 200) {
const responseArrayBuffer = await response.arrayBuffer();

return responseArrayBuffer;
} else {
const responseJson = await response.json();

throw responseJson.error || "WASM binary error";
}
} catch (e: any) {
throw `Error downloading WASM. ${e}`;
}
},
enabled: false,
});

return query;
};