diff --git a/package.json b/package.json index cf13118b..9f562364 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@tanstack/react-query": "^5.28.8", "@tanstack/react-query-devtools": "^5.28.8", "@typescript-eslint/eslint-plugin": "^7.5.0", + "bignumber.js": "^9.1.2", "bindings-js": "file:./src/temp/stellar-xdr-web", "dompurify": "^3.0.11", "html-react-parser": "^5.1.10", "immer": "^10.0.4", "lodash": "^4.17.21", + "lossless-json": "^4.0.1", "next": "14.1.4", "react": "^18", "react-dom": "^18", diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index 356c13c8..1496e50b 100644 --- a/src/app/(sidebar)/transaction/build/page.tsx +++ b/src/app/(sidebar)/transaction/build/page.tsx @@ -2,7 +2,10 @@ import { useEffect, useState } from "react"; import { Alert, Button, Card } from "@stellar/design-system"; +import { MemoValue } from "@stellar/stellar-sdk"; import { get, omit, set } from "lodash"; +import { stringify } from "lossless-json"; + import * as StellarXdr from "@/helpers/StellarXdr"; import { TabView } from "@/components/TabView"; @@ -19,13 +22,13 @@ import { InputSideElement } from "@/components/InputSideElement"; import { sanitizeObject } from "@/helpers/sanitizeObject"; import { isEmptyObject } from "@/helpers/isEmptyObject"; -import { inputNumberValue } from "@/helpers/inputNumberValue"; import { useStore } from "@/store/useStore"; +import { TransactionBuildParams } from "@/store/createStore"; import { Routes } from "@/constants/routes"; import { useAccountSequenceNumber } from "@/query/useAccountSequenceNumber"; import { validate } from "@/validate"; -import { AnyObject, EmptyObj } from "@/types/types"; +import { EmptyObj, KeysOfUnion } from "@/types/types"; export default function BuildTransaction() { const { transaction, network } = useStore(); @@ -33,9 +36,15 @@ export default function BuildTransaction() { const { updateBuildActiveTab, updateBuildParams } = transaction; const [isReady, setIsReady] = useState(false); - const [paramsError, setParamsError] = useState({}); + const [paramsError, setParamsError] = useState({}); const requiredParams = ["source_account", "seq_num", "fee"] as const; + type RequiredParamsField = (typeof requiredParams)[number]; + type ParamsField = KeysOfUnion; + + type ParamsError = { + [K in keyof TransactionBuildParams]?: any; + }; const { data: sequenceNumberData, @@ -62,23 +71,80 @@ export default function BuildTransaction() { } }; - // Need this to make sure the page doesn't render before we get the store data + const validateParam = (param: ParamsField, value: any) => { + switch (param) { + case "cond": + return validate.timeBounds(value?.time || value); + case "fee": + return validate.positiveInt(value); + case "memo": + if (!value || isEmptyObject(value)) { + return false; + } + + // Memo in store is in transaction format { memoType: memoValue } + if (value.type) { + return validate.memo(value); + } else { + // Changing it to { type, value } format if needed + const [type, val] = Object.entries(value)[0]; + return validate.memo({ type, value: val as MemoValue }); + } + + case "seq_num": + return validate.positiveInt(value); + case "source_account": + return validate.publicKey(value); + default: + return false; + } + }; + useEffect(() => { + // Stellar XDR init const init = async () => { await StellarXdr.init(); }; init(); + // Need this to make sure the page doesn't render before we get the store data setIsReady(true); }, []); - // TODO: validate fields on page load + useEffect(() => { + Object.entries(txnParams).forEach(([key, val]) => { + if (val) { + validateParam(key as ParamsField, val); + } + }); + + const validationError = Object.entries(txnParams).reduce((res, param) => { + const key = param[0] as ParamsField; + const val = param[1]; + + if (val) { + const error = validateParam(key, val); + + if (error) { + res[key] = key === "cond" ? { time: error } : error; + } + } + + return res; + }, {} as ParamsError); + + if (!isEmptyObject(validationError)) { + setParamsError(validationError); + } + // Run this only when page loads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { if (sequenceNumberData || sequenceNumberError) { const id = "seq_num"; - handleParamChange(id, inputNumberValue(sequenceNumberData)); + handleParamChange(id, sequenceNumberData); handleError(id, sequenceNumberError); } // Not inlcuding handleParamChange and handleError @@ -118,16 +184,45 @@ export default function BuildTransaction() { } return res; - }, [] as string[]); + }, [] as RequiredParamsField[]); }; - const renderTempXdr = () => { - const missingParams = missingRequiredParams(); + const getFieldLabel = (field: ParamsField) => { + switch (field) { + case "fee": + return "Base Fee"; + case "seq_num": + return "Transaction Sequence Number"; + case "source_account": + return "Source Account"; + case "cond": + return "Time Bounds"; + case "memo": + return "Memo"; + default: + return ""; + } + }; + + const getParamsError = () => { + const allErrorMessages: string[] = []; + const errors = Object.keys(paramsError); + + // Make sure we don't show multiple errors for the same field + const missingParams = missingRequiredParams().filter( + (m) => !errors.includes(m), + ); + // Missing params if (missingParams.length > 0) { - return
{`Required fields: ${missingParams.join(", ")}.`}
; + const missingParamsMsg = missingParams.reduce((res, cur) => { + return [...res, `${getFieldLabel(cur)} is a required field`]; + }, [] as string[]); + + allErrorMessages.push(...missingParamsMsg); } + // Memo value const memoValue = txnParams.memo; if ( @@ -135,57 +230,81 @@ export default function BuildTransaction() { !isEmptyObject(memoValue) && !Object.values(memoValue)[0] ) { - return
Need memo value
; + allErrorMessages.push( + "Memo value is required when memo type is selected", + ); } + // Fields with errors if (!isEmptyObject(paramsError)) { - return
There are errors
; + const fieldErrors = errors.reduce((res, cur) => { + return [ + ...res, + `${getFieldLabel(cur as ParamsField)} field has an error`, + ]; + }, [] as string[]); + + allErrorMessages.push(...fieldErrors); + } + + return allErrorMessages; + }; + + const txnJsonToXdr = () => { + if (getParamsError().length !== 0) { + return {}; } try { + // TODO: remove this formatter once Stellar XDR supports strings for numbers. // Format values to meet XDR requirements - const prepTxnParams = Object.entries(txnParams).reduce( - (res, [key, value]) => { - let val; - - switch (key) { - case "seq_num": - val = Number(value); - break; - case "fee": - val = Number(value); - break; - case "cond": - val = { - time: { - min_time: (value as any)?.time?.min_time - ? Number((value as any)?.time?.min_time) - : 0, - max_time: (value as any)?.time?.max_time - ? Number((value as any)?.time?.max_time) - : 0, - }, - }; - break; - case "memo": - if ((value as any)?.id) { - val = { id: Number((value as any).id) }; - } else { - val = - typeof value === "object" && isEmptyObject(value) - ? "none" - : value; - } - - break; - default: - val = value; - } - - return { ...res, [key]: val }; - }, - {}, - ); + const prepTxnParams = Object.entries(txnParams).reduce((res, param) => { + const key = param[0] as ParamsField; + // Casting to any type for simplicity + const value = param[1] as any; + + let val; + + switch (key) { + case "seq_num": + val = BigInt(value); + break; + case "fee": + val = BigInt(value); + break; + case "cond": + // eslint-disable-next-line no-case-declarations + const minTime = value?.time?.min_time; + // eslint-disable-next-line no-case-declarations + const maxTime = value?.time?.max_time; + + val = { + time: { + min_time: minTime ? BigInt(minTime) : 0, + max_time: maxTime ? BigInt(maxTime) : 0, + }, + }; + break; + case "memo": + // eslint-disable-next-line no-case-declarations + const memoId = value?.id; + + if (memoId) { + val = { id: BigInt(memoId) }; + } else { + val = + typeof value === "object" && isEmptyObject(value) + ? "none" + : value; + } + + break; + default: + val = value; + } + + return { ...res, [key]: val }; + }, {}); const txnJson = { tx: { @@ -195,22 +314,40 @@ export default function BuildTransaction() { operations: [], ext: "v0", }, - // TODO: add signatures signatures: [], }, }; - const xdr = StellarXdr.encode( - "TransactionEnvelope", - JSON.stringify(txnJson), - ); + // TODO: Temp fix until Stellar XDR supports strings for big numbers + // const jsonString = JSON.stringify(txnJson); + const jsonString = stringify(txnJson); - return
{`XDR: ${xdr}`}
; + return { + xdr: StellarXdr.encode("TransactionEnvelope", jsonString || ""), + }; } catch (e) { - return
{`XDR error: ${e}`}
; + return { error: `XDR error: ${e}` }; } }; + const BuildingError = ({ errorList }: { errorList: string[] }) => { + if (errorList.length === 0) { + return null; + } + + // TODO: style + return ( + +
Transaction building errors:
+
    + {errorList.map((e, i) => ( +
  • {e}
  • + ))} +
+
+ ); + }; + // TODO: add info links const renderParams = () => { return ( @@ -225,9 +362,9 @@ export default function BuildTransaction() { value={txnParams.source_account} error={paramsError.source_account} onChange={(e) => { - const id = e.target.id; + const id = "source_account"; handleParamChange(id, e.target.value); - handleError(id, validate.publicKey(e.target.value)); + handleError(id, validateParam(id, e.target.value)); }} note={ <> @@ -245,24 +382,21 @@ export default function BuildTransaction() { id="seq_num" label="Transaction Sequence Number" placeholder="Ex: 559234806710273" - value={txnParams.seq_num?.toString() || ""} + value={txnParams.seq_num} error={paramsError.seq_num} onChange={(e) => { - const id = e.target.id; - handleParamChange(id, inputNumberValue(e.target.value)); - handleError(id, validate.positiveInt(e.target.value)); - }} - onBlur={(e) => { - handleError( - e.target.id, - validate.positiveInt(e.target.value), - ); + const id = "seq_num"; + handleParamChange(id, e.target.value); + handleError(id, validateParam(id, e.target.value)); }} note="The transaction sequence number is usually one higher than current account sequence number." rightElement={ fetchSequenceNumber()} + onClick={() => { + handleParamChange("seq_num", ""); + fetchSequenceNumber(); + }} placement="right" disabled={ !txnParams.source_account || paramsError.source_account @@ -279,18 +413,12 @@ export default function BuildTransaction() { { - const id = e.target.id; - handleParamChange(id, inputNumberValue(e.target.value)); - handleError(id, validate.positiveInt(e.target.value)); - }} - onBlur={(e) => { - handleError( - e.target.id, - validate.positiveInt(e.target.value), - ); + const id = "fee"; + handleParamChange(id, e.target.value); + handleError(id, validateParam(id, e.target.value)); }} note={ <> @@ -314,22 +442,22 @@ export default function BuildTransaction() { onChange={(_, memo) => { const id = "memo"; handleParamChange(id, getMemoValue(memo)); - handleError(id, validate.memo(memo)); + handleError(id, validateParam(id, memo)); }} /> { const id = "cond.time"; handleParamChange(id, timeBounds); - handleError(id, validate.timeBounds(timeBounds)); + handleError(id, validateParam("cond", timeBounds)); }} /> @@ -355,7 +483,7 @@ export default function BuildTransaction() { submitted to the network. - {renderTempXdr()} + ); @@ -363,7 +491,15 @@ export default function BuildTransaction() { // TODO: render operations const renderOperations = () => { - return Operations; + const txnXdr = txnJsonToXdr(); + + return ( + + Operations + {/* TODO: style XDR and handle error */} +
{txnXdr.xdr ?? null}
+
+ ); }; if (!isReady) { diff --git a/src/components/FormElements/PositiveIntPicker.tsx b/src/components/FormElements/PositiveIntPicker.tsx index 2a6f8876..a1d322ee 100644 --- a/src/components/FormElements/PositiveIntPicker.tsx +++ b/src/components/FormElements/PositiveIntPicker.tsx @@ -10,7 +10,6 @@ interface PositiveIntPickerProps extends Omit { placeholder?: string; error: string | undefined; onChange: (e: React.ChangeEvent) => void; - onBlur?: (e: React.ChangeEvent) => void; } export const PositiveIntPicker = ({ @@ -21,7 +20,6 @@ export const PositiveIntPicker = ({ value, error, onChange, - onBlur, ...props }: PositiveIntPickerProps) => { return ( @@ -33,7 +31,6 @@ export const PositiveIntPicker = ({ value={value} error={error} onChange={onChange} - onBlur={onBlur} {...props} /> ); diff --git a/src/components/FormElements/TimeBoundsPicker.tsx b/src/components/FormElements/TimeBoundsPicker.tsx index 9717db99..94e7ff5d 100644 --- a/src/components/FormElements/TimeBoundsPicker.tsx +++ b/src/components/FormElements/TimeBoundsPicker.tsx @@ -4,8 +4,8 @@ import { InputSideElement } from "@/components/InputSideElement"; import { PositiveIntPicker } from "./PositiveIntPicker"; type TimeBoundValue = { - min_time: string | undefined; - max_time: string | undefined; + min_time: string; + max_time: string; }; type TimeBoundsPickerProps = { @@ -18,7 +18,7 @@ type TimeBoundsPickerProps = { export const TimeBoundsPicker = ({ id, - value = { min_time: undefined, max_time: undefined }, + value = { min_time: "", max_time: "" }, labelSuffix, onChange, error, diff --git a/src/query/useAccountSequenceNumber.ts b/src/query/useAccountSequenceNumber.ts index b9d000fe..fdd102f0 100644 --- a/src/query/useAccountSequenceNumber.ts +++ b/src/query/useAccountSequenceNumber.ts @@ -1,3 +1,4 @@ +import { MuxedAccount, StrKey } from "@stellar/stellar-sdk"; import { useQuery } from "@tanstack/react-query"; export const useAccountSequenceNumber = ({ @@ -10,7 +11,14 @@ export const useAccountSequenceNumber = ({ const query = useQuery({ queryKey: ["useAccountSequenceNumber", { publicKey }], queryFn: async () => { - const response = await fetch(`${horizonUrl}/accounts/${publicKey}`); + let sourceAccount = publicKey; + + if (StrKey.isValidMed25519PublicKey(publicKey)) { + const muxedAccount = MuxedAccount.fromAddress(publicKey, "0"); + sourceAccount = muxedAccount.baseAccount().accountId(); + } + + const response = await fetch(`${horizonUrl}/accounts/${sourceAccount}`); const responseJson = await response.json(); if (responseJson?.status === 0) { diff --git a/src/store/createStore.ts b/src/store/createStore.ts index 4ff94bbb..e1b23e5d 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -6,14 +6,14 @@ import { MemoType } from "@stellar/stellar-sdk"; import { sanitizeObject } from "@/helpers/sanitizeObject"; import { AnyObject, EmptyObj, Network, MuxedAccount } from "@/types/types"; -type TransactionBuildParams = { +export type TransactionBuildParams = { source_account: string; - fee: number | undefined; - seq_num: number | undefined; + fee: string; + seq_num: string; cond: { time: { - min_time: number | undefined; - max_time: number | undefined; + min_time: string; + max_time: string; }; }; memo: @@ -96,12 +96,12 @@ const initTransactionState = { activeTab: "params", params: { source_account: "", - fee: undefined, - seq_num: undefined, + fee: "", + seq_num: "", cond: { time: { - min_time: undefined, - max_time: undefined, + min_time: "", + max_time: "", }, }, memo: {}, diff --git a/src/types/types.ts b/src/types/types.ts index 8268d9d9..b92c81d7 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -6,6 +6,12 @@ import React from "react"; export type AnyObject = { [key: string]: any }; export type EmptyObj = Record; +// ============================================================================= +// Helpers +// ============================================================================= +// Key union type from object keys +export type KeysOfUnion = T extends infer P ? keyof P : never; + // ============================================================================= // Network // ============================================================================= @@ -82,6 +88,14 @@ export type AssetObject = { value: AssetObjectValue; }; +// ============================================================================= +// Transaction +// ============================================================================= +export type TimeBoundsValue = { + min_time: number | string | undefined; + max_time: number | string | undefined; +}; + // ============================================================================= // Component // ============================================================================= diff --git a/src/validate/methods/timeBounds.ts b/src/validate/methods/timeBounds.ts index 1f847906..a9ae65fa 100644 --- a/src/validate/methods/timeBounds.ts +++ b/src/validate/methods/timeBounds.ts @@ -1,14 +1,9 @@ -import { sanitizeObject } from "@/helpers/sanitizeObject"; import { positiveInt } from "./positiveInt"; +import { sanitizeObject } from "@/helpers/sanitizeObject"; import { isEmptyObject } from "@/helpers/isEmptyObject"; +import { TimeBoundsValue } from "@/types/types"; -export const timeBounds = ({ - min_time, - max_time, -}: { - min_time: number | string | undefined; - max_time: number | string | undefined; -}) => { +export const timeBounds = ({ min_time, max_time }: TimeBoundsValue) => { const validated = sanitizeObject({ min_time: min_time ? positiveInt(min_time.toString()) : (false as const), max_time: max_time ? positiveInt(max_time.toString()) : (false as const), diff --git a/yarn.lock b/yarn.lock index 1cddbdac..ac8e9df3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2143,6 +2143,11 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lossless-json@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lossless-json/-/lossless-json-4.0.1.tgz#d45229e3abb213a0235812780ca894ea8c5b2c6b" + integrity sha512-l0L+ppmgPDnb+JGxNLndPtJZGNf6+ZmVaQzoxQm3u6TXmhdnsA+YtdVR8DjzZd/em58686CQhOFDPewfJ4l7MA== + lru-cache@^10.2.0: version "10.2.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3"