diff --git a/src/app/(sidebar)/endpoints/[[...pages]]/page.tsx b/src/app/(sidebar)/endpoints/[[...pages]]/page.tsx index 0bb86c63..c75cce56 100644 --- a/src/app/(sidebar)/endpoints/[[...pages]]/page.tsx +++ b/src/app/(sidebar)/endpoints/[[...pages]]/page.tsx @@ -20,6 +20,7 @@ import { SdsLink } from "@/components/SdsLink"; import { NextLink } from "@/components/NextLink"; import { formComponentTemplate } from "@/components/formComponentTemplate"; import { PrettyJson } from "@/components/PrettyJson"; +import { InputSideElement } from "@/components/InputSideElement"; import { useStore } from "@/store/useStore"; import { isEmptyObject } from "@/helpers/isEmptyObject"; @@ -448,12 +449,13 @@ export default function Endpoints() { readOnly disabled leftElement={ -
{pageData.requestMethod} -
+ } /> + + + + The transaction builder lets you build a new Stellar transaction. diff --git a/src/components/FormElements/MemoPicker.tsx b/src/components/FormElements/MemoPicker.tsx new file mode 100644 index 00000000..2f755985 --- /dev/null +++ b/src/components/FormElements/MemoPicker.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { MemoType, MemoValue } from "@stellar/stellar-sdk"; +import { Input } from "@stellar/design-system"; + +import { RadioPicker } from "@/components/RadioPicker"; +import { ExpandBox } from "@/components/ExpandBox"; + +type MemoPickerValue = { type: MemoType; value: MemoValue | undefined }; + +type MemoPickerProps = { + id: string; + value: MemoPickerValue | undefined; + onChange: ( + optionId: string | undefined, + optionValue?: MemoPickerValue, + ) => void; + labelSuffix?: string | React.ReactNode; + error: string | undefined; +}; + +export const MemoPicker = ({ + id, + value, + onChange, + labelSuffix, + error, +}: MemoPickerProps) => { + const memoValuePlaceholder = (type?: MemoType) => { + if (!type) { + return ""; + } + + switch (type) { + case "id": + return "Unsigned 64-bit integer"; + case "hash": + case "return": + return "32-byte hash in hexadecimal format (64 [0-9a-f] characters)"; + case "text": + return "UTF-8 string of up to 28 bytes"; + case "none": + default: + return ""; + } + }; + + return ( +
+ + + + { + if (value?.type) { + onChange(value.type, { type: value.type, value: e.target.value }); + } + }} + error={error} + /> + +
+ ); +}; diff --git a/src/components/FormElements/TimeBoundsPicker.tsx b/src/components/FormElements/TimeBoundsPicker.tsx new file mode 100644 index 00000000..6a9e9ebe --- /dev/null +++ b/src/components/FormElements/TimeBoundsPicker.tsx @@ -0,0 +1,89 @@ +import { Box } from "@/components/layout/Box"; +import { SdsLink } from "@/components/SdsLink"; +import { InputSideElement } from "@/components/InputSideElement"; +import { PositiveIntPicker } from "./PositiveIntPicker"; + +type TimeBoundValue = { min: string; max: string }; + +type TimeBoundsPickerProps = { + value: TimeBoundValue | undefined; + labelSuffix?: string | React.ReactNode; + onChange: (value: TimeBoundValue) => void; + error: { min: string | false; max: string | false } | undefined; +}; + +export const TimeBoundsPicker = ({ + value = { min: "", max: "" }, + labelSuffix, + onChange, + error, +}: TimeBoundsPickerProps) => { + return ( + + <> + + <> + { + onChange({ ...value, min: e.target.value }); + }} + /> + { + onChange({ ...value, max: e.target.value }); + }} + rightElement={ + { + onChange({ + ...value, + max: ( + Math.ceil(new Date().getTime() / 1000) + + 5 * 60 + ).toString(), + }); + }} + > + Set to 5 min from now + + } + /> + + + + <> +
+ Enter{" "} + + unix timestamp + {" "} + values of time bounds when this transaction will be valid. +
+ +
+ For regular transactions, it is highly recommended to set the + upper time bound to get a{" "} + + final result + {" "} + of a transaction in a defined time. +
+ +
+ +
+ ); +}; diff --git a/src/components/InputSideElement/index.tsx b/src/components/InputSideElement/index.tsx new file mode 100644 index 00000000..eb436b02 --- /dev/null +++ b/src/components/InputSideElement/index.tsx @@ -0,0 +1,58 @@ +import { Button } from "@stellar/design-system"; +import "./styles.scss"; + +type InputSideElementProps = ( + | { + variant: "text"; + onClick?: undefined; + disabled?: undefined; + title?: undefined; + } + | { + variant: "button"; + onClick: () => void; + disabled?: boolean; + title?: string; + } +) & { + children: string; + placement: "left" | "right"; +}; + +export const InputSideElement = ({ + variant, + children, + onClick, + disabled, + title, + placement = "right", + ...props +}: InputSideElementProps) => { + if (variant === "text") { + return ( +
+ {children} +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/src/components/InputSideElement/styles.scss b/src/components/InputSideElement/styles.scss new file mode 100644 index 00000000..64a307fb --- /dev/null +++ b/src/components/InputSideElement/styles.scss @@ -0,0 +1,52 @@ +@use "../../styles/utils.scss" as *; + +.InputSideElement { + flex-shrink: 0; + flex-grow: 0; + + &--text { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + font-weight: var(--sds-fw-semi-bold); + color: var(--sds-clr-gray-12); + background-color: var(--sds-clr-gray-01); + height: 100%; + display: flex; + align-items: center; + flex-shrink: 0; + flex-grow: 0; + padding: pxToRem(6px) pxToRem(10px); + } + + &--left { + border-right: 1px solid var(--Input-color-border); + margin-left: calc(var(--Input-padding-horizontal) * -1); + border-top-left-radius: var(--Input-border-radius); + border-bottom-left-radius: var(--Input-border-radius); + + .Button { + border-top-left-radius: var(--Input-border-radius); + border-bottom-left-radius: var(--Input-border-radius); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + &--right { + border-left: 1px solid var(--Input-color-border); + margin-right: calc(var(--Input-padding-horizontal) * -1); + border-top-right-radius: var(--Input-border-radius); + border-bottom-right-radius: var(--Input-border-radius); + + .Button { + border-top-right-radius: var(--Input-border-radius); + border-bottom-right-radius: var(--Input-border-radius); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + .Button { + border: none; + } +} diff --git a/src/components/RadioPicker/index.tsx b/src/components/RadioPicker/index.tsx index 90e83df0..9b412ba2 100644 --- a/src/components/RadioPicker/index.tsx +++ b/src/components/RadioPicker/index.tsx @@ -1,7 +1,5 @@ import React from "react"; - import { Label } from "@stellar/design-system"; -import { AssetType } from "@/types/types"; import "./styles.scss"; interface RadioPickerProps { @@ -9,10 +7,7 @@ interface RadioPickerProps { selectedOption: string | undefined; label?: string | React.ReactNode; labelSuffix?: string | React.ReactNode; - onChange: ( - optionId: AssetType | undefined, - optionValue?: TOptionValue, - ) => void; + onChange: (optionId: any | undefined, optionValue?: TOptionValue) => void; options: { id: string; label: string; @@ -53,7 +48,7 @@ export const RadioPicker = ({ id={opId} checked={curId === selectedOption} onChange={() => { - onChange(o.id as AssetType, o.value); + onChange(o.id, o.value); }} onClick={() => { if (curId === selectedOption) { diff --git a/src/helpers/capitalizeString.ts b/src/helpers/capitalizeString.ts new file mode 100644 index 00000000..4f0c8c1f --- /dev/null +++ b/src/helpers/capitalizeString.ts @@ -0,0 +1,2 @@ +export const capitalizeString = (text: string) => + (text && text[0].toUpperCase() + text.slice(1)) || text; diff --git a/src/query/useAccountSequenceNumber.ts b/src/query/useAccountSequenceNumber.ts new file mode 100644 index 00000000..b9d000fe --- /dev/null +++ b/src/query/useAccountSequenceNumber.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; + +export const useAccountSequenceNumber = ({ + publicKey, + horizonUrl, +}: { + publicKey: string; + horizonUrl: string; +}) => { + const query = useQuery({ + queryKey: ["useAccountSequenceNumber", { publicKey }], + queryFn: async () => { + const response = await fetch(`${horizonUrl}/accounts/${publicKey}`); + const responseJson = await response.json(); + + if (responseJson?.status === 0) { + throw `Unable to reach server at ${horizonUrl}.`; + } + + if (responseJson?.status?.toString()?.startsWith("4")) { + if (responseJson?.title === "Resource Missing") { + throw "Account not found. Make sure the correct network is selected and the account is funded/created."; + } + + throw ( + responseJson?.extras?.reason || + responseJson?.detail || + "Something went wrong when fetching the transaction sequence number. Please try again." + ); + } + + return (BigInt(responseJson.sequence) + BigInt(1)).toString(); + }, + enabled: false, + }); + + return query; +}; diff --git a/src/styles/globals.scss b/src/styles/globals.scss index a71b2792..3205da61 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -389,25 +389,6 @@ gap: pxToRem(8px); } - &__input__requestType { - font-size: pxToRem(14px); - line-height: pxToRem(20px); - font-weight: var(--sds-fw-semi-bold); - color: var(--sds-clr-gray-12); - background-color: var(--sds-clr-gray-01); - height: 100%; - display: flex; - align-items: center; - flex-shrink: 0; - flex-grow: 0; - padding: pxToRem(6px) pxToRem(10px); - - border-right: 1px solid var(--Input-color-border); - margin-left: calc(var(--Input-padding-horizontal) * -1); - border-top-left-radius: var(--Input-border-radius); - border-bottom-left-radius: var(--Input-border-radius); - } - &__content { display: flex; flex-direction: column; diff --git a/src/validate/index.ts b/src/validate/index.ts index 7a456c3e..04f32f3c 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -2,8 +2,10 @@ import { amount } from "./methods/amount"; import { asset } from "./methods/asset"; import { assetCode } from "./methods/assetCode"; import { assetMulti } from "./methods/assetMulti"; +import { memo } from "./methods/memo"; import { positiveInt } from "./methods/positiveInt"; import { publicKey } from "./methods/publicKey"; +import { timeBounds } from "./methods/timeBounds"; import { transactionHash } from "./methods/transactionHash"; import { xdr } from "./methods/xdr"; @@ -12,8 +14,10 @@ export const validate = { asset, assetCode, assetMulti, + memo, positiveInt, publicKey, + timeBounds, transactionHash, xdr, }; diff --git a/src/validate/methods/memo.ts b/src/validate/methods/memo.ts new file mode 100644 index 00000000..e8cf7962 --- /dev/null +++ b/src/validate/methods/memo.ts @@ -0,0 +1,37 @@ +import { capitalizeString } from "@/helpers/capitalizeString"; +import { MemoType, MemoValue, xdr } from "@stellar/stellar-sdk"; + +export const memo = ({ type, value }: { type: MemoType; value: MemoValue }) => { + switch (type) { + case "text": + // eslint-disable-next-line no-case-declarations + const memoTextBytes = Buffer.byteLength(value as string, "utf8"); + + if (memoTextBytes > 28) { + return `Memo Text accepts a string of up to 28 bytes. ${memoTextBytes} bytes entered.`; + } + + return false; + case "id": + if (!value?.toString().match(/^[0-9]*$/g) || Number(value) < 0) { + return "Memo ID accepts a positive integer."; + } + + // Checking UnsignedHyper + if (value !== xdr.Uint64.fromString(value as string).toString()) { + return `Memo ID is an unsigned 64-bit integer and the max valid + value is 18446744073709551615`; + } + + return false; + case "hash": + case "return": + if (!value?.toString().match(/^[0-9a-f]{64}$/gi)) { + return `Memo ${capitalizeString(type)} accepts a 32-byte hash in hexadecimal format (64 characters).`; + } + + return false; + default: + return false; + } +}; diff --git a/src/validate/methods/timeBounds.ts b/src/validate/methods/timeBounds.ts new file mode 100644 index 00000000..6c2efc69 --- /dev/null +++ b/src/validate/methods/timeBounds.ts @@ -0,0 +1,11 @@ +import { positiveInt } from "./positiveInt"; + +export const timeBounds = ({ + min, + max, +}: { + min: string; + max: string; +}): { min: string | false; max: string | false } => { + return { min: positiveInt(min), max: positiveInt(max) }; +};