From 36d83766bc929b1a60aa4fa2b6796a739bd9b441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?jeesun=20=EC=A7=80=EC=84=A0?= Date: Mon, 3 Feb 2025 11:45:03 -0800 Subject: [PATCH] [Build Operations] Include Soroban's extend-ttl (#1235) --- .../build/components/ClassicOperation.tsx | 525 ++++++++++++ ...ctionXdr.tsx => ClassicTransactionXdr.tsx} | 69 +- .../build/components/Operations.tsx | 746 +++++------------- .../build/components/SorobanOperation.tsx | 273 +++++++ .../components/SorobanTransactionXdr.tsx | 118 +++ .../components/TransactionXdrDisplay.tsx | 66 ++ src/app/(sidebar)/transaction/build/page.tsx | 11 +- .../ResourceFeePickerWithQuery.tsx | 187 +++++ .../formComponentTemplateTxnOps.tsx | 127 +++ src/constants/transactionOperations.tsx | 43 +- src/helpers/sorobanUtils.ts | 179 +++++ src/types/types.ts | 7 + tests/buildTransaction.test.ts | 160 +++- 13 files changed, 1909 insertions(+), 602 deletions(-) create mode 100644 src/app/(sidebar)/transaction/build/components/ClassicOperation.tsx rename src/app/(sidebar)/transaction/build/components/{TransactionXdr.tsx => ClassicTransactionXdr.tsx} (88%) create mode 100644 src/app/(sidebar)/transaction/build/components/SorobanOperation.tsx create mode 100644 src/app/(sidebar)/transaction/build/components/SorobanTransactionXdr.tsx create mode 100644 src/app/(sidebar)/transaction/build/components/TransactionXdrDisplay.tsx create mode 100644 src/components/FormElements/ResourceFeePickerWithQuery.tsx create mode 100644 src/helpers/sorobanUtils.ts diff --git a/src/app/(sidebar)/transaction/build/components/ClassicOperation.tsx b/src/app/(sidebar)/transaction/build/components/ClassicOperation.tsx new file mode 100644 index 00000000..f7993c81 --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/ClassicOperation.tsx @@ -0,0 +1,525 @@ +import { ChangeEvent, Fragment, useState } from "react"; +import { Card, Badge, Button, Icon, Input } from "@stellar/design-system"; + +import { TabbedButtons } from "@/components/TabbedButtons"; +import { Box } from "@/components/layout/Box"; +import { formComponentTemplateTxnOps } from "@/components/formComponentTemplateTxnOps"; +import { ShareUrlButton } from "@/components/ShareUrlButton"; +import { SaveToLocalStorageModal } from "@/components/SaveToLocalStorageModal"; + +import { arrayItem } from "@/helpers/arrayItem"; +import { getClaimableBalanceIdFromXdr } from "@/helpers/getClaimableBalanceIdFromXdr"; +import { sanitizeObject } from "@/helpers/sanitizeObject"; +import { shareableUrl } from "@/helpers/shareableUrl"; +import { localStorageSavedTransactions } from "@/helpers/localStorageSavedTransactions"; + +import { useStore } from "@/store/useStore"; + +import { OP_SET_TRUST_LINE_FLAGS } from "@/constants/settings"; +import { + INITIAL_OPERATION, + SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE, + TRANSACTION_OPERATIONS, +} from "@/constants/transactionOperations"; + +import { + AnyObject, + AssetObject, + AssetObjectValue, + AssetPoolShareObjectValue, + NumberFractionValue, + OperationError, + OptionSigner, + RevokeSponsorshipValue, +} from "@/types/types"; + +export const ClassicOperation = ({ + operationTypeSelector: OperationTypeSelector, + operationsError, + setOperationsError, + updateOptionParamAndError, + validateOperationParam, + renderSourceAccount, +}: { + operationTypeSelector: React.ComponentType<{ + index: number; + operationType: string; + }>; + operationsError: OperationError[]; + setOperationsError: (operationsError: OperationError[]) => void; + updateOptionParamAndError: (params: { + type: + | "add" + | "delete" + | "move-before" + | "move-after" + | "duplicate" + | "reset"; + index?: number; + item?: any; + }) => void; + validateOperationParam: (params: { + opIndex: number; + opParam: string; + opValue: any; + opType: string; + }) => OperationError; + renderSourceAccount: (opType: string, index: number) => React.ReactNode; +}) => { + const { transaction, network } = useStore(); + const { classic } = transaction.build; + const { operations: txnOperations, xdr: txnXdr } = classic; + + const [isSaveTxnModalVisible, setIsSaveTxnModalVisible] = useState(false); + + const { updateBuildSingleOperation } = transaction; + + /* Classic Operations */ + const handleOperationParamChange = ({ + opIndex, + opParam, + opValue, + opType, + }: { + opIndex: number; + opParam: string; + opValue: any; + opType: string; + }) => { + const op = txnOperations[opIndex]; + + updateBuildSingleOperation(opIndex, { + ...op, + params: sanitizeObject({ + ...op?.params, + [opParam]: opValue, + }), + }); + + const validatedOpParam = validateOperationParam({ + opIndex, + opParam, + opValue, + opType, + }); + + const updatedOpParamError = arrayItem.update( + operationsError, + opIndex, + validatedOpParam, + ); + + setOperationsError([...updatedOpParamError]); + }; + + const OperationTabbedButtons = ({ + index, + isUpDisabled, + isDownDisabled, + isDeleteDisabled, + }: { + index: number; + isUpDisabled: boolean; + isDownDisabled: boolean; + isDeleteDisabled: boolean; + }) => { + return ( + , + onClick: () => + updateOptionParamAndError({ + type: "move-before", + index, + }), + isDisabled: isUpDisabled, + }, + { + id: "moveDown", + hoverTitle: "Move down", + icon: , + onClick: () => + updateOptionParamAndError({ + type: "move-after", + index, + }), + isDisabled: isDownDisabled, + }, + { + id: "duplicate", + hoverTitle: "Duplicate", + icon: , + onClick: () => + updateOptionParamAndError({ + type: "duplicate", + index, + }), + }, + { + id: "delete", + hoverTitle: "Delete", + icon: , + isError: true, + isDisabled: isDeleteDisabled, + onClick: () => + updateOptionParamAndError({ + type: "delete", + index, + }), + }, + ]} + /> + ); + }; + + const renderClaimableBalanceId = (opIndex: number) => { + const balanceId = getClaimableBalanceIdFromXdr({ + xdr: txnXdr, + networkPassphrase: network.passphrase, + opIndex, + }); + + if (!balanceId) { + return null; + } + + return ( + + ); + }; + + return ( + + + + {/* Operations */} + <> + {txnOperations.map((op, idx) => ( + + {/* Operation label and action buttons */} + + {`Operation ${idx}`} + + + + + + + {/* Operation params */} + <> + {TRANSACTION_OPERATIONS[op.operation_type]?.params.map( + (input) => { + const component = formComponentTemplateTxnOps({ + param: input, + opType: op.operation_type, + index: idx, + custom: + TRANSACTION_OPERATIONS[op.operation_type].custom?.[ + input + ], + }); + const baseProps = { + value: txnOperations[idx]?.params[input], + error: operationsError[idx]?.error?.[input], + isRequired: + TRANSACTION_OPERATIONS[ + op.operation_type + ].requiredParams.includes(input), + }; + + if (component) { + switch (input) { + case "asset": + case "buying": + case "selling": + case "send_asset": + case "dest_asset": + return component.render({ + ...baseProps, + onChange: (assetValue: AssetObjectValue) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: assetValue, + opType: op.operation_type, + }); + }, + }); + case "authorize": + return component.render({ + ...baseProps, + onChange: (selected: string | undefined) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: selected, + opType: op.operation_type, + }); + }, + }); + case "claimants": + return ( + + {component.render({ + ...baseProps, + onChange: ( + claimants: AnyObject[] | undefined, + ) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: claimants, + opType: op.operation_type, + }); + }, + })} + + {renderClaimableBalanceId(idx)} + + ); + case "line": + return component.render({ + ...baseProps, + onChange: ( + assetValue: + | AssetObjectValue + | AssetPoolShareObjectValue, + ) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: assetValue, + opType: op.operation_type, + }); + }, + }); + case "min_price": + case "max_price": + return component.render({ + ...baseProps, + onChange: (value: NumberFractionValue) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: value, + opType: op.operation_type, + }); + }, + }); + case "path": + return component.render({ + ...baseProps, + onChange: (path: AssetObject[]) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: path, + opType: op.operation_type, + }); + }, + }); + case "revokeSponsorship": + return component.render({ + ...baseProps, + onChange: ( + value: RevokeSponsorshipValue | undefined, + ) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: value, + opType: op.operation_type, + }); + }, + }); + case "clear_flags": + case "set_flags": + return component.render({ + ...baseProps, + onChange: (value: string[]) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: value.length > 0 ? value : undefined, + opType: op.operation_type, + }); + + if ( + op.operation_type === OP_SET_TRUST_LINE_FLAGS + ) { + const txOp = txnOperations[idx]; + + // If checking a flag, remove the message (the + // other flag doesn't matter). + // If unchecking a flag, check if the other + // flag is checked. + const showCustomMessage = + value.length > 0 + ? false + : input === "clear_flags" + ? !txOp.params.set_flags + : !txOp.params.clear_flags; + + const opError = { + ...operationsError[idx], + customMessage: showCustomMessage + ? [SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE] + : [], + }; + const updated = arrayItem.update( + operationsError, + idx, + opError, + ); + + setOperationsError(updated); + } + }, + }); + case "signer": + return component.render({ + ...baseProps, + onChange: (value: OptionSigner | undefined) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: value, + opType: op.operation_type, + }); + }, + }); + default: + return component.render({ + ...baseProps, + onChange: (e: ChangeEvent) => { + handleOperationParamChange({ + opIndex: idx, + opParam: input, + opValue: e.target.value, + opType: op.operation_type, + }); + }, + }); + } + } + + return null; + }, + )} + + + {/* Optional source account for all operations */} + <>{renderSourceAccount(op.operation_type, idx)} + + ))} + + + {/* Operations bottom buttons */} + + + + + + + + + + + + + + + { + setIsSaveTxnModalVisible(false); + }} + onUpdate={(updatedItems) => { + localStorageSavedTransactions.set(updatedItems); + }} + /> + + ); +}; diff --git a/src/app/(sidebar)/transaction/build/components/TransactionXdr.tsx b/src/app/(sidebar)/transaction/build/components/ClassicTransactionXdr.tsx similarity index 88% rename from src/app/(sidebar)/transaction/build/components/TransactionXdr.tsx rename to src/app/(sidebar)/transaction/build/components/ClassicTransactionXdr.tsx index 1637be24..68d4aefd 100644 --- a/src/app/(sidebar)/transaction/build/components/TransactionXdr.tsx +++ b/src/app/(sidebar)/transaction/build/components/ClassicTransactionXdr.tsx @@ -1,17 +1,13 @@ "use client"; import { useEffect } from "react"; -import { Button } from "@stellar/design-system"; import { stringify } from "lossless-json"; import { StrKey, TransactionBuilder } from "@stellar/stellar-sdk"; import { set } from "lodash"; import * as StellarXdr from "@/helpers/StellarXdr"; import { useRouter } from "next/navigation"; -import { SdsLink } from "@/components/SdsLink"; import { ValidationResponseCard } from "@/components/ValidationResponseCard"; -import { Box } from "@/components/layout/Box"; -import { ViewInXdrButton } from "@/components/ViewInXdrButton"; import { isEmptyObject } from "@/helpers/isEmptyObject"; import { xdrUtils } from "@/helpers/xdr/utils"; @@ -39,10 +35,11 @@ import { OptionSigner, TxnOperation, } from "@/types/types"; +import { TransactionXdrDisplay } from "./TransactionXdrDisplay"; const MAX_INT64 = "9223372036854775807"; -export const TransactionXdr = () => { +export const ClassicTransactionXdr = () => { const { transaction, network } = useStore(); const router = useRouter(); const { classic, params: txnParams, isValid } = transaction.build; @@ -537,58 +534,16 @@ export const TransactionXdr = () => { .toString("hex"); return ( - -
-
Network Passphrase:
-
{network.passphrase}
-
-
-
Hash:
-
{txnHash}
-
-
-
XDR:
-
{txnXdr.xdr}
-
- - } - note={ - <> - In order for the transaction to make it into the ledger, a - transaction must be successfully signed and submitted to the - network. The Lab provides the{" "} - - Transaction Signer - {" "} - for signing a transaction, and the{" "} - - Post Transaction endpoint - {" "} - for submitting one to the network. - - } - footerLeftEl={ - <> - - - - - } + { + updateSignImportXdr(txnXdr.xdr); + updateSignActiveView("overview"); + router.push(Routes.SIGN_TRANSACTION); + }} /> ); } catch (e: any) { diff --git a/src/app/(sidebar)/transaction/build/components/Operations.tsx b/src/app/(sidebar)/transaction/build/components/Operations.tsx index 41eb55e7..773a2819 100644 --- a/src/app/(sidebar)/transaction/build/components/Operations.tsx +++ b/src/app/(sidebar)/transaction/build/components/Operations.tsx @@ -1,81 +1,58 @@ "use client"; -import { ChangeEvent, Fragment, useEffect, useState } from "react"; -import { - Badge, - Button, - Card, - Icon, - Select, - Notification, - Input, -} from "@stellar/design-system"; +import { ChangeEvent, useEffect, useState } from "react"; +import { Select, Notification } from "@stellar/design-system"; import { formComponentTemplateTxnOps } from "@/components/formComponentTemplateTxnOps"; -import { Box } from "@/components/layout/Box"; -import { TabbedButtons } from "@/components/TabbedButtons"; import { SdsLink } from "@/components/SdsLink"; -import { ShareUrlButton } from "@/components/ShareUrlButton"; -import { SaveToLocalStorageModal } from "@/components/SaveToLocalStorageModal"; +import { SorobanOperation } from "./SorobanOperation"; +import { ClassicOperation } from "./ClassicOperation"; import { arrayItem } from "@/helpers/arrayItem"; import { isEmptyObject } from "@/helpers/isEmptyObject"; -import { sanitizeObject } from "@/helpers/sanitizeObject"; -import { shareableUrl } from "@/helpers/shareableUrl"; -import { getClaimableBalanceIdFromXdr } from "@/helpers/getClaimableBalanceIdFromXdr"; -import { localStorageSavedTransactions } from "@/helpers/localStorageSavedTransactions"; +import { isSorobanOperationType } from "@/helpers/sorobanUtils"; import { OP_SET_TRUST_LINE_FLAGS } from "@/constants/settings"; import { + EMPTY_OPERATION_ERROR, INITIAL_OPERATION, TRANSACTION_OPERATIONS, + SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE, } from "@/constants/transactionOperations"; import { useStore } from "@/store/useStore"; import { AnyObject, - AssetObject, AssetObjectValue, - AssetPoolShareObjectValue, NumberFractionValue, OpBuildingError, + OperationError, OptionSigner, RevokeSponsorshipValue, } from "@/types/types"; export const Operations = () => { - const { transaction, network } = useStore(); - const { classic } = transaction.build; - const { operations: txnOperations, xdr: txnXdr } = classic; + const { transaction } = useStore(); + const { classic, soroban } = transaction.build; + + // Classic Operations + const { operations: txnOperations } = classic; + // Soroban Operation + const { operation: sorobanOperation } = soroban; const { // Classic updateBuildOperations, updateBuildSingleOperation, + // Soroban + updateSorobanBuildOperation, // Either Classic or (@todo) Soroban updateBuildIsValid, setBuildOperationsError, } = transaction; - // Types - type OperationError = { - operationType: string; - error: { [key: string]: string }; - missingFields: string[]; - customMessage: string[]; - }; - const [operationsError, setOperationsError] = useState([]); - const [isSaveTxnModalVisible, setIsSaveTxnModalVisible] = useState(false); - - const EMPTY_OPERATION_ERROR: OperationError = { - operationType: "", - error: {}, - missingFields: [], - customMessage: [], - }; - - const SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE = "At least one flag is required"; + // For Classic Operations const updateOptionParamAndError = ({ type, index, @@ -140,71 +117,143 @@ export const Operations = () => { } }; + // For Soroban Operation + const resetSorobanOperation = () => { + updateSorobanBuildOperation(INITIAL_OPERATION); + setOperationsError([EMPTY_OPERATION_ERROR]); + }; + // Preserve values and validate inputs when components mounts useEffect(() => { // If no operations to preserve, add inital operation and error template - if (txnOperations.length === 0) { + if (txnOperations.length === 0 && !soroban.operation.operation_type) { + // Default to classic operations empty state updateOptionParamAndError({ type: "add", item: INITIAL_OPERATION }); } else { - // Validate all params in all operations + // If there are operations on mount, validate all params in all operations const errors: OperationError[] = []; - txnOperations.forEach((op, idx) => { - const opRequiredFields = [ - ...(TRANSACTION_OPERATIONS[op.operation_type]?.requiredParams || []), + // Soroban operation params validation + if (soroban.operation.operation_type) { + const sorobanOpRequiredFields = [ + ...(TRANSACTION_OPERATIONS[soroban.operation.operation_type] + ?.requiredParams || []), ]; - const missingFields = opRequiredFields.reduce((res, cur) => { - if (!op.params[cur]) { - return [...res, cur]; - } - - return res; - }, [] as string[]); + const sorobanMissingFields = sorobanOpRequiredFields.reduce( + (res, cur) => { + if (!soroban.operation.params[cur]) { + return [...res, cur]; + } + return res; + }, + [] as string[], + ); - let opErrors: OperationError = { + // Soroban Operation Error related + let sorobanOpErrors: OperationError = { ...EMPTY_OPERATION_ERROR, - missingFields, - operationType: op.operation_type, + missingFields: sorobanMissingFields, + operationType: soroban.operation.operation_type, }; - // Params - Object.entries(op.params).forEach(([key, value]) => { - opErrors = { - ...opErrors, + // Soroban: Validate params + Object.entries(soroban.operation.params).forEach(([key, value]) => { + sorobanOpErrors = { + ...sorobanOpErrors, ...validateOperationParam({ - opIndex: idx, + // setting index to 0 because only one operation is allowed with Soroban + opIndex: 0, opParam: key, opValue: value, - opParamError: opErrors, - opType: op.operation_type, + opParamError: sorobanOpErrors, + opType: soroban.operation.operation_type, }), }; }); - // Source account - if (op.source_account) { - opErrors = { - ...opErrors, + // Validate source account if present + if (soroban.operation.source_account) { + sorobanOpErrors = { + ...sorobanOpErrors, ...validateOperationParam({ - opIndex: idx, + opIndex: 0, // setting index to 0 because only one operation is allowed with Soroban opParam: "source_account", - opValue: op.source_account, - opParamError: opErrors, - opType: op.operation_type, + opValue: soroban.operation.source_account, + opParamError: sorobanOpErrors, + opType: soroban.operation.operation_type, }), }; } - // Missing optional selection - opErrors = operationCustomMessage({ - opType: op.operation_type, - opIndex: idx, - opError: opErrors, + // Check for custom messages + sorobanOpErrors = operationCustomMessage({ + opType: soroban.operation.operation_type, + opIndex: 0, // setting index to 0 because only one operation is allowed with Soroban + opError: sorobanOpErrors, }); - errors.push(opErrors); - }); + errors.push(sorobanOpErrors); + } else { + // Classic operation params validation + txnOperations.forEach((op, idx) => { + const opRequiredFields = [ + ...(TRANSACTION_OPERATIONS[op.operation_type]?.requiredParams || + []), + ]; + + const missingFields = opRequiredFields.reduce((res, cur) => { + if (!op.params[cur]) { + return [...res, cur]; + } + + return res; + }, [] as string[]); + + let opErrors: OperationError = { + ...EMPTY_OPERATION_ERROR, + missingFields, + operationType: op.operation_type, + }; + + // Params + Object.entries(op.params).forEach(([key, value]) => { + opErrors = { + ...opErrors, + ...validateOperationParam({ + opIndex: idx, + opParam: key, + opValue: value, + opParamError: opErrors, + opType: op.operation_type, + }), + }; + }); + + // Source account + if (op.source_account) { + opErrors = { + ...opErrors, + ...validateOperationParam({ + opIndex: idx, + opParam: "source_account", + opValue: op.source_account, + opParamError: opErrors, + opType: op.operation_type, + }), + }; + } + + // Missing optional selection + opErrors = operationCustomMessage({ + opType: op.operation_type, + opIndex: idx, + opError: opErrors, + }); + + errors.push(opErrors); + }); + } setOperationsError([...errors]); } @@ -217,7 +266,12 @@ export const Operations = () => { setBuildOperationsError(getOperationsError()); // Not including getOperationsError() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [txnOperations, operationsError, setBuildOperationsError]); + }, [ + txnOperations, + sorobanOperation.operation_type, + operationsError, + setBuildOperationsError, + ]); const missingSelectedAssetFields = ( param: string, @@ -571,54 +625,28 @@ export const Operations = () => { }; }; - const handleOperationParamChange = ({ - opIndex, - opParam, - opValue, - opType, - }: { - opIndex: number; - opParam: string; - opValue: any; - opType: string; - }) => { - const op = txnOperations[opIndex]; - - updateBuildSingleOperation(opIndex, { - ...op, - params: sanitizeObject({ - ...op?.params, - [opParam]: opValue, - }), - }); - - const validatedOpParam = validateOperationParam({ - opIndex, - opParam, - opValue, - opType, - }); - const updatedOpParamError = arrayItem.update( - operationsError, - opIndex, - validatedOpParam, - ); - - setOperationsError([...updatedOpParamError]); - }; - const handleOperationSourceAccountChange = ( opIndex: number, opValue: any, opType: string, + isSoroban: boolean = false, ) => { - const op = txnOperations[opIndex]; - - updateBuildSingleOperation(opIndex, { - ...op, - source_account: opValue, - }); + if (isSoroban) { + // Handle Soroban operation + updateSorobanBuildOperation({ + ...sorobanOperation, + source_account: opValue, + }); + } else { + // Handle classic operation + const op = txnOperations[opIndex]; + updateBuildSingleOperation(opIndex, { + ...op, + source_account: opValue, + }); + } + // Validation logic is the same for both const validatedSourceAccount = validateOperationParam({ opIndex, opParam: "source_account", @@ -690,6 +718,10 @@ export const Operations = () => { }; const renderSourceAccount = (opType: string, index: number) => { + const currentOperation = isSorobanOperationType(opType) + ? sorobanOperation + : txnOperations[index]; + const sourceAccountComponent = formComponentTemplateTxnOps({ param: "source_account", opType, @@ -698,80 +730,21 @@ export const Operations = () => { return opType && sourceAccountComponent ? sourceAccountComponent.render({ - value: txnOperations[index].source_account, + value: currentOperation.source_account, error: operationsError[index]?.error?.["source_account"], isRequired: false, onChange: (e: ChangeEvent) => { - handleOperationSourceAccountChange(index, e.target.value, opType); + handleOperationSourceAccountChange( + index, + e.target.value, + opType, + isSorobanOperationType(opType), + ); }, }) : null; }; - const OperationTabbedButtons = ({ - index, - isUpDisabled, - isDownDisabled, - isDeleteDisabled, - }: { - index: number; - isUpDisabled: boolean; - isDownDisabled: boolean; - isDeleteDisabled: boolean; - }) => { - return ( - , - onClick: () => - updateOptionParamAndError({ - type: "move-before", - index, - }), - isDisabled: isUpDisabled, - }, - { - id: "moveDown", - hoverTitle: "Move down", - icon: , - onClick: () => - updateOptionParamAndError({ - type: "move-after", - index, - }), - isDisabled: isDownDisabled, - }, - { - id: "duplicate", - hoverTitle: "Duplicate", - icon: , - onClick: () => - updateOptionParamAndError({ - type: "duplicate", - index, - }), - }, - { - id: "delete", - hoverTitle: "Delete", - icon: , - isError: true, - isDisabled: isDeleteDisabled, - onClick: () => - updateOptionParamAndError({ - type: "delete", - index, - }), - }, - ]} - /> - ); - }; - const renderCustom = (operationType: string) => { if (operationType === "allow_trust") { return ( @@ -808,11 +781,23 @@ export const Operations = () => { TRANSACTION_OPERATIONS[e.target.value]?.defaultParams || {}; const defaultParamKeys = Object.keys(defaultParams); - updateBuildSingleOperation(index, { - operation_type: e.target.value, - params: defaultParams, - source_account: "", - }); + if (isSorobanOperationType(e.target.value)) { + // if it's soroban, reset the classic operation + updateOptionParamAndError({ type: "reset" }); + updateSorobanBuildOperation({ + operation_type: e.target.value, + params: defaultParams, + source_account: "", + }); + } else { + // if it's classic, reset the soroban operation + resetSorobanOperation(); + updateBuildSingleOperation(index, { + operation_type: e.target.value, + params: defaultParams, + source_account: "", + }); + } let initParamError: OperationError = EMPTY_OPERATION_ERROR; @@ -842,9 +827,15 @@ export const Operations = () => { }); } - setOperationsError([ - ...arrayItem.update(operationsError, index, initParamError), - ]); + if (isSorobanOperationType(e.target.value)) { + // for soroban, on operation dropdown change, we don't need to update the existing array + // since there is only one operation + setOperationsError([initParamError]); + } else { + setOperationsError([ + ...arrayItem.update(operationsError, index, initParamError), + ]); + } }} note={ opInfo ? ( @@ -857,7 +848,7 @@ export const Operations = () => { > - @@ -907,350 +898,33 @@ export const Operations = () => { ); }; - const renderClaimableBalanceId = (opIndex: number) => { - const balanceId = getClaimableBalanceIdFromXdr({ - xdr: txnXdr, - networkPassphrase: network.passphrase, - opIndex, - }); - - if (!balanceId) { - return null; - } - + /* Soroban Operations */ + // Unlike classic transactions, Soroban tx can only have one operation + if (soroban.operation.operation_type) { return ( - + } + operationsError={operationsError} + setOperationsError={setOperationsError} + validateOperationParam={validateOperationParam} + renderSourceAccount={renderSourceAccount} /> ); - }; + } return ( - - - - {/* Operations */} - <> - {txnOperations.map((op, idx) => ( - - {/* Operation label and action buttons */} - - {`Operation ${idx}`} - - - - - - - {/* Operation params */} - <> - {TRANSACTION_OPERATIONS[op.operation_type]?.params.map( - (input) => { - const component = formComponentTemplateTxnOps({ - param: input, - opType: op.operation_type, - index: idx, - custom: - TRANSACTION_OPERATIONS[op.operation_type].custom?.[ - input - ], - }); - const baseProps = { - value: txnOperations[idx]?.params[input], - error: operationsError[idx]?.error?.[input], - isRequired: - TRANSACTION_OPERATIONS[ - op.operation_type - ].requiredParams.includes(input), - }; - - if (component) { - switch (input) { - case "asset": - case "buying": - case "selling": - case "send_asset": - case "dest_asset": - return component.render({ - ...baseProps, - onChange: (assetValue: AssetObjectValue) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: assetValue, - opType: op.operation_type, - }); - }, - }); - case "authorize": - return component.render({ - ...baseProps, - onChange: (selected: string | undefined) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: selected, - opType: op.operation_type, - }); - }, - }); - case "claimants": - return ( - - {component.render({ - ...baseProps, - onChange: ( - claimants: AnyObject[] | undefined, - ) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: claimants, - opType: op.operation_type, - }); - }, - })} - - {renderClaimableBalanceId(idx)} - - ); - case "line": - return component.render({ - ...baseProps, - onChange: ( - assetValue: - | AssetObjectValue - | AssetPoolShareObjectValue, - ) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: assetValue, - opType: op.operation_type, - }); - }, - }); - case "min_price": - case "max_price": - return component.render({ - ...baseProps, - onChange: (value: NumberFractionValue) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: value, - opType: op.operation_type, - }); - }, - }); - case "path": - return component.render({ - ...baseProps, - onChange: (path: AssetObject[]) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: path, - opType: op.operation_type, - }); - }, - }); - case "revokeSponsorship": - return component.render({ - ...baseProps, - onChange: ( - value: RevokeSponsorshipValue | undefined, - ) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: value, - opType: op.operation_type, - }); - }, - }); - case "clear_flags": - case "set_flags": - return component.render({ - ...baseProps, - onChange: (value: string[]) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: value.length > 0 ? value : undefined, - opType: op.operation_type, - }); - - if ( - op.operation_type === OP_SET_TRUST_LINE_FLAGS - ) { - const txOp = txnOperations[idx]; - - // If checking a flag, remove the message (the - // other flag doesn't matter). - // If unchecking a flag, check if the other - // flag is checked. - const showCustomMessage = - value.length > 0 - ? false - : input === "clear_flags" - ? !txOp.params.set_flags - : !txOp.params.clear_flags; - - const opError = { - ...operationsError[idx], - customMessage: showCustomMessage - ? [SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE] - : [], - }; - const updated = arrayItem.update( - operationsError, - idx, - opError, - ); - - setOperationsError(updated); - } - }, - }); - case "signer": - return component.render({ - ...baseProps, - onChange: (value: OptionSigner | undefined) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: value, - opType: op.operation_type, - }); - }, - }); - default: - return component.render({ - ...baseProps, - onChange: (e: ChangeEvent) => { - handleOperationParamChange({ - opIndex: idx, - opParam: input, - opValue: e.target.value, - opType: op.operation_type, - }); - }, - }); - } - } - - return null; - }, - )} - - - {/* Optional source account for all operations */} - <>{renderSourceAccount(op.operation_type, idx)} - - ))} - - - {/* Operations bottom buttons */} - - - - - - - - - - - - - - - { - setIsSaveTxnModalVisible(false); - }} - onUpdate={(updatedItems) => { - localStorageSavedTransactions.set(updatedItems); - }} - /> - + ); }; diff --git a/src/app/(sidebar)/transaction/build/components/SorobanOperation.tsx b/src/app/(sidebar)/transaction/build/components/SorobanOperation.tsx new file mode 100644 index 00000000..cfc04c8c --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/SorobanOperation.tsx @@ -0,0 +1,273 @@ +import { ChangeEvent, useState } from "react"; + +import { + Badge, + Button, + Card, + Icon, + Notification, +} from "@stellar/design-system"; + +import { Box } from "@/components/layout/Box"; +import { formComponentTemplateTxnOps } from "@/components/formComponentTemplateTxnOps"; +import { ShareUrlButton } from "@/components/ShareUrlButton"; +import { SaveToLocalStorageModal } from "@/components/SaveToLocalStorageModal"; + +import { localStorageSavedTransactions } from "@/helpers/localStorageSavedTransactions"; +import { shareableUrl } from "@/helpers/shareableUrl"; + +import { useStore } from "@/store/useStore"; +import { + EMPTY_OPERATION_ERROR, + INITIAL_OPERATION, + TRANSACTION_OPERATIONS, +} from "@/constants/transactionOperations"; +import { OperationError } from "@/types/types"; + +export const SorobanOperation = ({ + operationTypeSelector, + operationsError, + setOperationsError, + validateOperationParam, + renderSourceAccount, +}: { + operationTypeSelector: React.ReactElement; + operationsError: OperationError[]; + setOperationsError: (operationsError: OperationError[]) => void; + validateOperationParam: (params: { + opIndex: number; + opParam: string; + opValue: any; + opType: string; + }) => OperationError; + renderSourceAccount: (opType: string, index: number) => React.ReactNode; +}) => { + const { transaction, network } = useStore(); + const { soroban } = transaction.build; + const { operation: sorobanOperation, xdr: sorobanTxnXdr } = soroban; + const { updateSorobanBuildOperation } = transaction; + + const [isSaveTxnModalVisible, setIsSaveTxnModalVisible] = useState(false); + + const resetSorobanOperation = () => { + updateSorobanBuildOperation(INITIAL_OPERATION); + setOperationsError([EMPTY_OPERATION_ERROR]); + }; + + const handleSorobanOperationParamChange = ({ + opParam, + opValue, + opType, + }: { + opParam: string; + opValue: any; + opType: string; + }) => { + const updatedOperation = { + ...sorobanOperation, + operation_type: opType, + params: { + ...sorobanOperation?.params, + [opParam]: opValue, + }, + }; + + updateSorobanBuildOperation(updatedOperation); + + // Validate the parameter + const validatedOpParam = validateOperationParam({ + // setting index to 0 because only one operation is allowed with Soroban + opIndex: 0, + opParam, + opValue, + opType, + }); + + setOperationsError([validatedOpParam]); + }; + + return ( + + + + + {/* Operation label and action buttons */} + + + Operation + + + + {operationTypeSelector} + + {/* RPC URL Validation */} + <> + {!network.rpcUrl ? ( + + + An RPC URL must be configured in the network settings to + proceed with a Soroban operation. + + + ) : null} + + + {/* Operation params */} + <> + {TRANSACTION_OPERATIONS[ + sorobanOperation.operation_type + ]?.params.map((input) => { + const component = formComponentTemplateTxnOps({ + param: input, + opType: sorobanOperation.operation_type, + index: 0, // no index for soroban operations + custom: + TRANSACTION_OPERATIONS[sorobanOperation.operation_type] + .custom?.[input], + }); + + // Soroban base props + const sorobanBaseProps = { + key: input, + value: sorobanOperation.params[input], + error: operationsError[0]?.error?.[input], + isRequired: + TRANSACTION_OPERATIONS[ + sorobanOperation.operation_type + ].requiredParams.includes(input), + isDisabled: Boolean(!network.rpcUrl), + }; + + if (component) { + switch (input) { + case "contract": + case "key_xdr": + case "extend_ttl_to": + case "resource_fee": + case "durability": + return component.render({ + ...sorobanBaseProps, + onChange: (e: ChangeEvent) => { + handleSorobanOperationParamChange({ + opParam: input, + opValue: e.target.value, + opType: sorobanOperation.operation_type, + }); + }, + }); + default: + return component.render({ + ...sorobanBaseProps, + onChange: (e: ChangeEvent) => { + handleSorobanOperationParamChange({ + opParam: input, + opValue: e.target.value, + opType: sorobanOperation.operation_type, + }); + }, + }); + } + } + + return null; + })} + + + {/* Optional source account for Soroban operations */} + <>{renderSourceAccount(sorobanOperation.operation_type, 0)} + + + + + Note that Soroban transactions can only contain one operation per + transaction. + + + + {/* Operations bottom buttons */} + + + + + + + + + + + + + + + { + setIsSaveTxnModalVisible(false); + }} + onUpdate={(updatedItems) => { + localStorageSavedTransactions.set(updatedItems); + }} + /> + + ); +}; diff --git a/src/app/(sidebar)/transaction/build/components/SorobanTransactionXdr.tsx b/src/app/(sidebar)/transaction/build/components/SorobanTransactionXdr.tsx new file mode 100644 index 00000000..5b727d1b --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/SorobanTransactionXdr.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect } from "react"; +import { useStore } from "@/store/useStore"; +import { TransactionBuilder } from "@stellar/stellar-sdk"; +import { useRouter } from "next/navigation"; + +import { + getSorobanTxData, + buildSorobanTx, + getContractDataXDR, +} from "@/helpers/sorobanUtils"; + +import { Routes } from "@/constants/routes"; + +import { SorobanOpType } from "@/types/types"; + +import { ValidationResponseCard } from "@/components/ValidationResponseCard"; +import { TransactionXdrDisplay } from "./TransactionXdrDisplay"; + +export const SorobanTransactionXdr = () => { + const { network, transaction } = useStore(); + const { updateSignActiveView, updateSignImportXdr, updateSorobanBuildXdr } = + transaction; + const { soroban, isValid, params: txnParams } = transaction.build; + const { operation } = soroban; + const router = useRouter(); + + useEffect(() => { + // Reset transaction.xdr if the transaction is not valid + if (!(isValid.params && isValid.operations)) { + updateSorobanBuildXdr(""); + } + // Not including updateBuildXdr + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isValid.params, isValid.operations]); + + const getSorobanTxDataResult = (): { xdr: string; error?: string } => { + try { + const contractDataXDR = getContractDataXDR({ + contractAddress: operation.params.contract, + dataKey: operation.params.key_xdr, + durability: operation.params.durability, + }); + + const sorobanData = getSorobanTxData({ + contractDataXDR, + operationType: operation.operation_type as SorobanOpType, + fee: operation.params.resource_fee, + }); + + if (sorobanData) { + const builtXdr = buildSorobanTx({ + sorobanData, + params: txnParams, + sorobanOp: operation, + networkPassphrase: network.passphrase, + }); + + const builtXdrString = builtXdr.toXDR(); + + return { xdr: builtXdrString }; + } else { + throw new Error("Failed to build Soroban transaction data"); + } + } catch (e) { + return { xdr: "", error: `${e}` }; + } + }; + + const sorobanData = getSorobanTxDataResult(); + const sorobanDataXdr = sorobanData?.xdr || ""; + + useEffect(() => { + if (sorobanDataXdr) { + updateSorobanBuildXdr(sorobanDataXdr); + } + }, [sorobanDataXdr, updateSorobanBuildXdr]); + + if (!(isValid.params && isValid.operations)) { + return null; + } + + if (sorobanData?.xdr) { + try { + const txnHash = TransactionBuilder.fromXDR( + sorobanData.xdr, + network.passphrase, + ) + .hash() + .toString("hex"); + + return ( + { + updateSignImportXdr(sorobanData.xdr); + updateSignActiveView("overview"); + router.push(Routes.SIGN_TRANSACTION); + }} + /> + ); + } catch (e: any) { + return ( + + ); + } + } + + return null; +}; diff --git a/src/app/(sidebar)/transaction/build/components/TransactionXdrDisplay.tsx b/src/app/(sidebar)/transaction/build/components/TransactionXdrDisplay.tsx new file mode 100644 index 00000000..5c147b7e --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/TransactionXdrDisplay.tsx @@ -0,0 +1,66 @@ +import { Button } from "@stellar/design-system"; + +import { SdsLink } from "@/components/SdsLink"; +import { ValidationResponseCard } from "@/components/ValidationResponseCard"; +import { Box } from "@/components/layout/Box"; +import { ViewInXdrButton } from "@/components/ViewInXdrButton"; +import { Routes } from "@/constants/routes"; + +interface TransactionXdrDisplayProps { + xdr: string; + networkPassphrase: string; + txnHash: string; + dataTestId: string; + onSignClick: () => void; +} + +export const TransactionXdrDisplay = ({ + xdr, + networkPassphrase, + txnHash, + dataTestId, + onSignClick, +}: TransactionXdrDisplayProps) => ( + +
+
Network Passphrase:
+
{networkPassphrase}
+
+
+
Hash:
+
{txnHash}
+
+
+
XDR:
+
{xdr}
+
+ + } + note={ + <> + In order for the transaction to make it into the ledger, a transaction + must be successfully signed and submitted to the network. The Lab + provides the{" "} + Transaction Signer for + signing a transaction, and the{" "} + + Post Transaction endpoint + {" "} + for submitting one to the network. + + } + footerLeftEl={ + <> + + + + + } + /> +); diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index 946a4c19..c2cf4fba 100644 --- a/src/app/(sidebar)/transaction/build/page.tsx +++ b/src/app/(sidebar)/transaction/build/page.tsx @@ -8,13 +8,20 @@ import { ValidationResponseCard } from "@/components/ValidationResponseCard"; import { Params } from "./components/Params"; import { Operations } from "./components/Operations"; -import { TransactionXdr } from "./components/TransactionXdr"; +import { ClassicTransactionXdr } from "./components/ClassicTransactionXdr"; +import { SorobanTransactionXdr } from "./components/SorobanTransactionXdr"; export default function BuildTransaction() { const { transaction } = useStore(); + + // For Classic const { params: paramsError, operations: operationsError } = transaction.build.error; + // For Soroban + const { soroban } = transaction.build; + const IS_SOROBAN_TX = Boolean(soroban.operation.operation_type); + const renderError = () => { if (paramsError.length > 0 || operationsError.length > 0) { return ( @@ -80,7 +87,7 @@ export default function BuildTransaction() { <>{renderError()} - + {IS_SOROBAN_TX ? : } ); } diff --git a/src/components/FormElements/ResourceFeePickerWithQuery.tsx b/src/components/FormElements/ResourceFeePickerWithQuery.tsx new file mode 100644 index 00000000..2ab7cf61 --- /dev/null +++ b/src/components/FormElements/ResourceFeePickerWithQuery.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +import { useSimulateTx } from "@/query/useSimulateTx"; + +import { useStore } from "@/store/useStore"; +import { SorobanOpType } from "@/types/types"; +import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; +import { + buildSorobanTx, + getContractDataXDR, + getSorobanTxData, +} from "@/helpers/sorobanUtils"; + +import { InputSideElement } from "@/components/InputSideElement"; +import { PositiveIntPicker } from "@/components/FormElements/PositiveIntPicker"; + +const isAllParamsExceptResourceFeeValid = (params: Record) => { + // Create a copy of params without resource_fee + const { ...requiredParams } = params; + + // Check if all remaining fields have truthy values + return Object.values(requiredParams).every((value) => Boolean(value)); +}; + +interface ResourceFeePickerWithQueryProps { + id: string; + labelSuffix?: string | React.ReactNode; + label: string; + value: string; + placeholder?: string; + error: string | undefined; + note?: React.ReactNode; + infoLink?: string; + disabled?: boolean; + onChange: (e: React.ChangeEvent) => void; +} + +// Used only for a Soroban operation +// Includes a simulate transaction button to fetch +// the minimum resource fee +export const ResourceFeePickerWithQuery = ({ + id, + labelSuffix, + label, + value, + error, + onChange, + placeholder, + note, + infoLink, + disabled, + ...props +}: ResourceFeePickerWithQueryProps) => { + const { network, transaction } = useStore(); + const { params: txnParams, soroban, isValid } = transaction.build; + const { operation } = soroban; + const { + mutateAsync: simulateTx, + data: simulateTxData, + isPending: isSimulateTxPending, + } = useSimulateTx(); + + const [errorMessage, setErrorMessage] = useState( + undefined, + ); + + // operation.params.resource_fee is required for submitting; however, + // we don't check for it here in case user doesn't have one and we still want to simulate + // @TODO should update this from isValid.operations point of view + // It doesn't validate the input at the moment + const isOperationValidForSimulateTx = isAllParamsExceptResourceFeeValid( + operation.params, + ); + + useEffect(() => { + // If the user has fetched the min resource fee, update the input's value + if (simulateTxData?.result?.minResourceFee) { + const syntheticEvent = { + target: { + value: simulateTxData.result.minResourceFee.toString(), + }, + } as React.ChangeEvent; + + onChange(syntheticEvent); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simulateTxData?.result?.minResourceFee]); + + // Create a sample transaction to simulate to get the min resource fee + const buildTxToSimulate = () => { + try { + // We add a bogus fee to simualte to fetch the min resource fee from the RPC + const BOGUS_RESOURCE_FEE = "100"; + + let builtXdr, contractDataXDR; + + try { + contractDataXDR = getContractDataXDR({ + contractAddress: operation.params.contract, + dataKey: operation.params.key_xdr, + durability: operation.params.durability, + }); + } catch (e) { + throw new Error(`Failed to generate contract data XDR: ${e}`); + } + + if (!contractDataXDR) { + throw new Error("Failed to fetch contract data XDR"); + } + + const sorobanData = getSorobanTxData({ + contractDataXDR, + operationType: operation.operation_type as SorobanOpType, + fee: BOGUS_RESOURCE_FEE, // simulate purpose only + }); + + if (sorobanData) { + builtXdr = buildSorobanTx({ + sorobanData, + params: txnParams, + sorobanOp: { + ...operation, + params: { + ...operation.params, + resource_fee: BOGUS_RESOURCE_FEE, + }, + }, + networkPassphrase: network.passphrase, + }).toXDR(); + } else { + throw new Error("Failed to build Soroban transaction data"); + } + return builtXdr; + } catch (e) { + setErrorMessage( + `Something went wrong when calculating a resource fee: ${e}`, + ); + return; + } + }; + + return ( + { + onChange(e); + setErrorMessage(undefined); + }} + infoLink={infoLink} + note={note} + disabled={disabled} + rightElement={ + { + const sampleTxnXdr = buildTxToSimulate(); + + if (!sampleTxnXdr) { + return; + } + + await simulateTx({ + rpcUrl: network.rpcUrl, + transactionXdr: sampleTxnXdr, + headers: getNetworkHeaders(network, "rpc"), + }); + }} + placement="right" + // if we can't build a txn to simulate, we can't fetch the min resource fee + disabled={!(isValid.params && isOperationValidForSimulateTx)} + isLoading={isSimulateTxPending} + > + Fetch minimum resource fee from RPC + + } + {...props} + /> + ); +}; diff --git a/src/components/formComponentTemplateTxnOps.tsx b/src/components/formComponentTemplateTxnOps.tsx index f3c48c8f..69ae7aae 100644 --- a/src/components/formComponentTemplateTxnOps.tsx +++ b/src/components/formComponentTemplateTxnOps.tsx @@ -1,4 +1,5 @@ import { JSX } from "react"; +import { Select } from "@stellar/design-system"; import { Box } from "@/components/layout/Box"; import { SdsLink } from "@/components/SdsLink"; @@ -14,6 +15,7 @@ import { AuthorizePicker } from "@/components/FormElements/AuthorizePicker"; import { NumberFractionPicker } from "@/components/FormElements/NumberFractionPicker"; import { RevokeSponsorshipPicker } from "@/components/FormElements/RevokeSponsorshipPicker"; import { ClaimantsPicker } from "@/components/FormElements/ClaimantsPicker"; +import { ResourceFeePickerWithQuery } from "@/components/FormElements/ResourceFeePickerWithQuery"; import { removeLeadingZeroes } from "@/helpers/removeLeadingZeroes"; @@ -36,6 +38,15 @@ type TemplateRenderProps = { isRequired?: boolean; }; +// Types +type SorobanTemplateRenderProps = { + value: string | undefined; + error: string | undefined; + onChange: (val: any) => void; + isRequired?: boolean; + isDisabled?: boolean; +}; + type TemplateRenderAssetProps = { value: AssetObjectValue | undefined; error: { code: string | undefined; issuer: string | undefined } | undefined; @@ -271,6 +282,27 @@ export const formComponentTemplateTxnOps = ({ ), validate: null, }; + case "contract": + return { + render: (templ: { + value: string | undefined; + error: string | undefined; + onChange: (val: any) => void; + isDisabled?: boolean; + }) => ( + + ), + validate: validate.getContractIdError, + }; case "data_name": return { render: (templ: TemplateRenderProps) => ( @@ -327,6 +359,56 @@ export const formComponentTemplateTxnOps = ({ ), validate: validate.getPublicKeyError, }; + case "durability": + return { + render: (templ: { + value: string | undefined; + error: string | undefined; + isDisabled?: boolean; + onChange: (val: any) => void; + }) => ( + + ), + validate: null, + }; + case "extend_ttl_to": { + return { + render: (templ: SorobanTemplateRenderProps) => ( + + ), + validate: validate.getPositiveIntError, + }; + } case "from": return { render: (templ: TemplateRenderProps) => ( @@ -375,6 +457,51 @@ export const formComponentTemplateTxnOps = ({ ), validate: validate.getPublicKeyError, }; + case "key_xdr": + return { + render: (templ: SorobanTemplateRenderProps) => ( + + ), + validate: null, + }; + case "resource_fee": + return { + render: (templ: SorobanTemplateRenderProps) => ( + + The best way to find the required resource fee for any smart + contract transaction is to use the{" "} + + simulateTransaction endpoint + {" "} + from the RPC, which enables you to send a preflight transaction + that will return the necessary resource values and resource fee. + + } + infoLink="https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering#resource-fee" + /> + ), + validate: validate.getPositiveNumberError, + }; case "limit": return { render: (templ: TemplateRenderProps) => ( diff --git a/src/constants/transactionOperations.tsx b/src/constants/transactionOperations.tsx index 08ac7a38..90edb20f 100644 --- a/src/constants/transactionOperations.tsx +++ b/src/constants/transactionOperations.tsx @@ -5,7 +5,7 @@ import { OPERATION_TRUSTLINE_CLEAR_FLAGS, OPERATION_TRUSTLINE_SET_FLAGS, } from "@/constants/settings"; -import { AnyObject, TxnOperation } from "@/types/types"; +import { AnyObject, OperationError, TxnOperation } from "@/types/types"; type TransactionOperation = { label: string; @@ -24,6 +24,16 @@ export const INITIAL_OPERATION: TxnOperation = { params: [], }; +export const EMPTY_OPERATION_ERROR: OperationError = { + operationType: "", + error: {}, + missingFields: [], + customMessage: [], +}; + +export const SET_TRUSTLINE_FLAGS_CUSTOM_MESSAGE = + "At least one flag is required"; + export const TRANSACTION_OPERATIONS: { [key: string]: TransactionOperation } = { create_account: { label: "Create Account", @@ -343,9 +353,36 @@ export const TRANSACTION_OPERATIONS: { [key: string]: TransactionOperation } = { "https://developers.stellar.org/docs/learn/fundamentals/transactions/list-of-operations#clawback", params: ["asset", "from", "amount"], requiredParams: ["asset", "from", "amount"], + }, + extend_footprint_ttl: { + label: "Extend Footprint TTL", + description: + "Extend the time to live (TTL) of entries for Soroban smart contracts. This operation extends the TTL of the entries specified in the readOnly footprint of the transaction so that they will live at least until the extendTo ledger sequence number is reached.", + docsUrl: + "https://developers.stellar.org/docs/learn/fundamentals/transactions/list-of-operations#extend-footprint-ttl", + params: [ + "contract", + "key_xdr", + "extend_ttl_to", + "durability", + "resource_fee", + ], + requiredParams: [ + "contract", + "key_xdr", + "extend_ttl_to", + "durability", + "resource_fee", + ], + defaultParams: { + durability: "persistent", + }, custom: { - asset: { - includeNative: false, + extend_ttl_to: { + note: "The ledger sequence number the entries will live until.", + }, + durability: { + note: "TTL for the temporary data can be extended; however, it is unsafe to rely on the extensions to preserve data since there is always a risk of losing temporary data", }, }, }, diff --git a/src/helpers/sorobanUtils.ts b/src/helpers/sorobanUtils.ts new file mode 100644 index 00000000..e09532c4 --- /dev/null +++ b/src/helpers/sorobanUtils.ts @@ -0,0 +1,179 @@ +import { + Address, + Contract, + Operation, + TransactionBuilder, + xdr, + Account, + Memo, + SorobanDataBuilder, +} from "@stellar/stellar-sdk"; + +import { TransactionBuildParams } from "@/store/createStore"; +import { SorobanOpType, TxnOperation } from "@/types/types"; + +export const isSorobanOperationType = (operationType: string) => { + // @TODO: add restore_footprint and invoke_host_function + return ["extend_footprint_ttl"].includes(operationType); +}; + +// https://developers.stellar.org/docs/learn/glossary#ledgerkey +// https://developers.stellar.org/docs/build/guides/archival/restore-data-js +// Setup contract data xdr that will be used to build Soroban Transaction Data +export const getContractDataXDR = ({ + contractAddress, + dataKey, + durability, +}: { + contractAddress: string; + dataKey: string; + durability: string; +}) => { + const contract: Contract = new Contract(contractAddress); + const address: Address = Address.fromString(contract.contractId()); + const xdrBinary = Buffer.from(dataKey, "base64"); + + const getXdrDurability = (durability: string) => { + switch (durability) { + case "persistent": + return xdr.ContractDataDurability.persistent(); + // https://developers.stellar.org/docs/build/guides/storage/choosing-the-right-storage#temporary-storage + // TTL for the temporary data can be extended; however, + // it is unsafe to rely on the extensions to preserve data since + // there is always a risk of losing temporary data + case "temporary": + return xdr.ContractDataDurability.temporary(); + default: + return xdr.ContractDataDurability.persistent(); + } + }; + + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: address.toScAddress(), + key: xdr.ScVal.fromXDR(xdrBinary), + durability: getXdrDurability(durability), + }), + ); +}; + +export const getSorobanTxData = ({ + contractDataXDR, + operationType, + fee, +}: { + contractDataXDR: xdr.LedgerKey; + operationType: SorobanOpType; + fee: string; +}): xdr.SorobanTransactionData | undefined => { + switch (operationType) { + case "extend_footprint_ttl": + return buildSorobanData({ + readOnlyXdrLedgerKey: [contractDataXDR], + resourceFee: fee, + }); + default: + return undefined; + } +}; + +export const buildSorobanTx = ({ + sorobanData, + params, + sorobanOp, + networkPassphrase, +}: { + sorobanData: xdr.SorobanTransactionData; + params: TransactionBuildParams; + sorobanOp: TxnOperation; + networkPassphrase: string; +}) => { + // decrement seq number by 1 because TransactionBuilder.build() + // will increment the seq number by 1 automatically + const txSeq = (BigInt(params.seq_num) - BigInt(1)).toString(); + const account = new Account(params.source_account, txSeq); + + // https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering + const totalTxFee = BigInt(params.fee) + BigInt(sorobanOp.params.resource_fee); + + const getMemoValue = (memoType: string, memoValue: string) => { + switch (memoType) { + case "text": + return Memo.text(memoValue); + case "id": + return Memo.id(memoValue); + case "hash": + return Memo.hash(memoValue); + case "return": + return Memo.return(memoValue); + default: + return Memo.none(); + } + }; + + const getTimeboundsValue = (timebounds: { + min_time: string; + max_time: string; + }) => { + return { + minTime: timebounds.min_time, + maxTime: timebounds.max_time, + }; + }; + + const getSorobanOp = (operationType: string) => { + switch (operationType) { + case "extend_footprint_ttl": + return Operation.extendFootprintTtl({ + extendTo: Number(sorobanOp.params.extend_ttl_to), + source: sorobanOp.source_account, + }); + // case "restore_footprint": + default: + throw new Error(`Unsupported Soroban operation type: ${operationType}`); + } + }; + + const transaction = new TransactionBuilder(account, { + fee: totalTxFee.toString(), + timebounds: getTimeboundsValue(params.cond.time), + }); + + if (Object.keys(params.memo).length > 0) { + const [type, val] = Object.entries(params.memo)[0]; + transaction.addMemo(getMemoValue(type, val)); + } + + return transaction + .setNetworkPassphrase(networkPassphrase) + .setSorobanData(sorobanData) + .addOperation(getSorobanOp(sorobanOp.operation_type)) + .build(); +}; + +// Preparing Soroban Transaction Data +const buildSorobanData = ({ + readOnlyXdrLedgerKey = [], + readWriteXdrLedgerKey = [], + resourceFee, + // instructions + // ReadableByteStreamController, +}: { + readOnlyXdrLedgerKey?: xdr.LedgerKey[]; + readWriteXdrLedgerKey?: xdr.LedgerKey[]; + resourceFee: string; +}) => { + // one of the two must be provided + if (!(readOnlyXdrLedgerKey && readWriteXdrLedgerKey)) { + return; + } + + // https://stellar.github.io/js-stellar-sdk/SorobanDataBuilder.html + // SorobanDataBuilder is a builder for xdr.SorobanTransactionData structures + // that will be used in tx builder + return new SorobanDataBuilder() + .setReadOnly(readOnlyXdrLedgerKey) + .setReadWrite(readWriteXdrLedgerKey) + .setResourceFee(resourceFee) + .build(); +}; diff --git a/src/types/types.ts b/src/types/types.ts index 34c4d87d..72598ab6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -138,6 +138,13 @@ export type TxnOperation = { source_account?: string; }; +export type OperationError = { + operationType: string; + error: { [key: string]: string }; + missingFields: string[]; + customMessage: string[]; +}; + export type OpBuildingError = { label?: string; errorList?: string[] }; export type LedgerErrorResponse = { diff --git a/tests/buildTransaction.test.ts b/tests/buildTransaction.test.ts index d58f8b6d..7d2a145c 100644 --- a/tests/buildTransaction.test.ts +++ b/tests/buildTransaction.test.ts @@ -1299,6 +1299,140 @@ test.describe("Build Transaction Page", () => { }); }); }); + + // Soroban Extend Footprint TTL + test.describe("Soroban Extend Footprint TTL", () => { + test("Happy path", async ({ page }) => { + const { operation_0 } = await selectOperationType({ + page, + opType: "extend_footprint_ttl", + }); + // we are going from classic operation to soroban operation + // so the classic operation should not be visible + await expect(operation_0).not.toBeVisible(); + + const soroban_operation = page.getByTestId( + "build-soroban-transaction-operation", + ); + + // Verify warning message about one operation limit + await expect( + page.getByText( + "Note that Soroban transactions can only contain one operation per transaction.", + ), + ).toBeVisible(); + + // Soroban Operation only allows one operation + // Add Operation button should be disabled + await expect(page.getByText("Add Operation")).toBeDisabled(); + + // Fill in required fields + await soroban_operation + .getByLabel("Contract ID") + .fill("CAQP53Z2GMZ6WVOKJWXMCVDLZYJ7GYVMWPAMWACPLEZRF2UEZW3B636S"); + await soroban_operation + .getByLabel("Key ScVal in XDR") + .fill( + "AAAAEAAAAAEAAAACAAAADwAAAAdDb3VudGVyAAAAABIAAAAAAAAAAH5MvQcuICNqcxGfJ6rKFvwi77h3WDZ2XVzA+LVRkCKD", + ); + await soroban_operation.getByLabel("Extend To").fill("30000"); + await soroban_operation + .getByLabel("Resource Fee (in stroops)") + .fill("20000"); + + await soroban_operation + .getByLabel("Durability") + .selectOption({ value: "persistent" }); + + await testOpSuccessHashAndXdr({ + isSorobanOp: true, + page, + hash: "f81b348c83b9e59781117a65b01e2332ef5a00523ec7daead11b91bcf56a0755", + xdr: "AAAAAgAAAAANLHqVohDTxPKQ3fawTPgHahe0TzJjJkWV1WakcbeADgAAToQAD95QAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGQAAAAAAAHUwAAAAAQAAAAAAAAABAAAABgAAAAEg/u86MzPrVcpNrsFUa84T82Kss8DLAE9ZMxLqhM22HwAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAAB+TL0HLiAjanMRnyeqyhb8Iu+4d1g2dl1cwPi1UZAigwAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAATiAAAAAA", + }); + }); + + test("Validation", async ({ page }) => { + const { operation_0 } = await selectOperationType({ + page, + opType: "extend_footprint_ttl", + }); + + await expect(operation_0).not.toBeVisible(); + + await testInputError({ + page, + isSorobanOp: true, + label: "Contract ID", + value: "aaa", + errorMessage: "The string must start with 'C'.", + }); + + await testInputError({ + page, + isSorobanOp: true, + label: "Contract ID", + value: "CAQP53Z2GMZ6WVOKJWXMCVDL", + errorMessage: + "The string length should be at least 52 characters long.", + }); + + await testInputError({ + page, + isSorobanOp: true, + label: "Extend To", + value: "aaa", + errorMessage: "Expected a whole number.", + }); + + await testInputError({ + page, + isSorobanOp: true, + label: "Resource Fee (in stroops)", + value: "aaa", + errorMessage: "Expected a whole number.", + }); + }); + + test("Check rendering between classic and soroban", async ({ page }) => { + // Select Soroban Operation + const { operation_0 } = await selectOperationType({ + page, + opType: "extend_footprint_ttl", + }); + // we are going from classic operation to soroban operation + // so the classic operation should not be visible + await expect(operation_0).not.toBeVisible(); + + const soroban_operation = page.getByTestId( + "build-soroban-transaction-operation", + ); + + // Verify warning message about one operation limit + await expect( + page.getByText( + "Note that Soroban transactions can only contain one operation per transaction.", + ), + ).toBeVisible(); + + // Soroban Operation only allows one operation + // Add Operation button should be disabled + await expect(page.getByText("Add Operation")).toBeDisabled(); + + // Select Classic Operation + await soroban_operation.getByLabel("Operation type").selectOption({ + value: "payment", + }); + + const classicOperation = page.getByTestId( + "build-transaction-operation-0", + ); + + await expect(classicOperation).toBeVisible(); + + await expect(page.getByText("Add Operation")).toBeVisible(); + }); + }); }); }); @@ -1353,12 +1487,22 @@ const testOpSuccessHashAndXdr = async ({ page, hash, xdr, + isSorobanOp = false, }: { page: Page; hash: string; xdr: string; + isSorobanOp?: boolean; }) => { - const txnSuccess = page.getByTestId("build-transaction-envelope-xdr"); + let txnSuccess; + + if (isSorobanOp) { + txnSuccess = page.getByTestId("build-soroban-transaction-envelope-xdr"); + } else { + txnSuccess = page.getByTestId("build-transaction-envelope-xdr"); + } + + await expect(txnSuccess).toBeVisible(); await expect(txnSuccess.getByText("Hash").locator("+ div")).toHaveText(hash); await expect(txnSuccess.getByText("XDR").locator("+ div")).toHaveText(xdr); @@ -1372,6 +1516,7 @@ const testInputError = async ({ nthErrorIndex = 0, nthLabelIndex = 0, exact = false, + isSorobanOp = false, }: { page: Page; label: string; @@ -1380,12 +1525,19 @@ const testInputError = async ({ nthErrorIndex?: number; nthLabelIndex?: number; exact?: boolean; + isSorobanOp?: boolean; }) => { - const operation_0 = page.getByTestId("build-transaction-operation-0"); + let operation; + + if (isSorobanOp) { + operation = page.getByTestId("build-soroban-transaction-operation"); + } else { + operation = page.getByTestId("build-transaction-operation-0"); + } - await operation_0.getByLabel(label, { exact }).nth(nthLabelIndex).fill(value); + await operation.getByLabel(label, { exact }).nth(nthLabelIndex).fill(value); await expect( - operation_0.getByText(errorMessage).nth(nthErrorIndex), + operation.getByText(errorMessage).nth(nthErrorIndex), ).toBeVisible(); };