diff --git a/packages/adena-extension/src/common/utils/gas-utils.ts b/packages/adena-extension/src/common/utils/gas-utils.ts new file mode 100644 index 000000000..c976c3403 --- /dev/null +++ b/packages/adena-extension/src/common/utils/gas-utils.ts @@ -0,0 +1,22 @@ +import BigNumber from 'bignumber.js'; +import { GasToken } from '@common/constants/token.constant'; + +export const convertRawGasAmountToDisplayAmount = (rawAmount: string | number): string => { + try { + if (!rawAmount) { + return '0'; + } + + return BigNumber(rawAmount) + .shiftedBy(-GasToken.decimals) + .toFixed(GasToken.decimals) + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, ''); + } catch (e) { + console.warn('[convertRawGasAmountToDisplayAmount] Failed to convert:', { + rawAmount, + error: e, + }); + return '0'; + } +}; diff --git a/packages/adena-extension/src/common/utils/signer-utils.ts b/packages/adena-extension/src/common/utils/signer-utils.ts new file mode 100644 index 000000000..f8bfa318f --- /dev/null +++ b/packages/adena-extension/src/common/utils/signer-utils.ts @@ -0,0 +1,48 @@ +import { Signature } from '@adena-wallet/sdk'; +import { publicKeyToAddress } from 'adena-module'; + +/** + * Extract the signer addresses from the Signature array. + * @param signatures - Signature array + * @returns + */ +export const extractSignerAddresses = async (signatures: Signature[]): Promise => { + if (!signatures || signatures.length === 0) { + return []; + } + + try { + const addresses = await Promise.all( + signatures.map(async (signature) => { + if (!signature?.pubKey?.value) { + return ''; + } + + try { + const fullBytes = Uint8Array.from(atob(signature.pubKey.value), (c) => c.charCodeAt(0)); + const pubKeyBytes = fullBytes.slice(2); + const address = await publicKeyToAddress(pubKeyBytes); + return address; + } catch (e) { + console.error('Failed to extract address from signature:', e); + return ''; + } + }), + ); + + return addresses.filter((addr) => addr !== ''); + } catch (e) { + console.error('Failed to extract signer addresses:', e); + return []; + } +}; + +/** + * Converts a Signature array into a serializable key string. + * A helper function for use as a queryKey in React Query. + * @param signatures - Signature array + * @returns + */ +export const serializeSignaturesKey = (signatures?: Signature[]): string => { + return signatures?.map((sig) => sig.pubKey?.value).join(',') || ''; +}; diff --git a/packages/adena-extension/src/common/validation/index.ts b/packages/adena-extension/src/common/validation/index.ts index 90223b309..894892eee 100644 --- a/packages/adena-extension/src/common/validation/index.ts +++ b/packages/adena-extension/src/common/validation/index.ts @@ -3,3 +3,6 @@ export * from './validation-message'; export * from './validation-password'; export * from './validation-token'; export * from './validation-wallet'; +export * from './validation-signature'; +export * from './validation-signed-document'; +export * from './validation-create-multisig-document'; diff --git a/packages/adena-extension/src/common/validation/validation-create-multisig-document.ts b/packages/adena-extension/src/common/validation/validation-create-multisig-document.ts new file mode 100644 index 000000000..c43bb0cdb --- /dev/null +++ b/packages/adena-extension/src/common/validation/validation-create-multisig-document.ts @@ -0,0 +1,55 @@ +import { MultisigConfig } from '@inject/types'; +import { validateInvalidAddress } from './validation-address-book'; + +/** + * Validates multisig config object and its required fields + */ +export const validateMultisigConfigExists = ( + multisigConfig: MultisigConfig | undefined, +): boolean => { + if (!multisigConfig) { + return false; + } + + return ( + 'signers' in multisigConfig && + 'threshold' in multisigConfig && + multisigConfig.signers !== undefined && + multisigConfig.threshold !== undefined + ); +}; + +/** + * Validates signers array format and minimum count + */ +export const validateMultisigSigners = (signers: any): boolean => { + return Array.isArray(signers) && signers.length >= 2; +}; + +/** + * Validates all signer addresses + */ +export const validateMultisigSignerAddresses = (signers: string[]): boolean => { + try { + for (const signer of signers) { + validateInvalidAddress(signer); + } + return true; + } catch (error) { + return false; + } +}; + +/** + * Validates threshold value + */ +export const validateMultisigThreshold = (threshold: any, signersCount: number): boolean => { + return typeof threshold === 'number' && threshold >= 1 && threshold <= signersCount; +}; + +/** + * Validates chain_id field + */ +export const validateChainId = (chain_id: any): boolean => { + return typeof chain_id === 'string' && chain_id.length > 0; +}; diff --git a/packages/adena-extension/src/common/validation/validation-signature.ts b/packages/adena-extension/src/common/validation/validation-signature.ts new file mode 100644 index 000000000..1989b93fd --- /dev/null +++ b/packages/adena-extension/src/common/validation/validation-signature.ts @@ -0,0 +1,37 @@ +/** + * Validates a single signature object structure. + * Verifies the existence and format of pubKey (typeUrl, value) and signature fields. + * + * @param signature - The signature object to validate + * @returns true if signature structure is valid, false otherwise + */ +export const validateSignature = (signature: any): boolean => { + if (!signature || typeof signature !== 'object') { + return false; + } + + if ( + !signature.pubKey || + typeof signature.pubKey.typeUrl !== 'string' || + typeof signature.pubKey.value !== 'string' + ) { + return false; + } + + if (typeof signature.signature !== 'string') { + return false; + } + + return true; +}; + +/** + * Validates an array of signature objects. + * Checks that all signatures in the array have valid structure. + * + * @param signatures - Array of signature objects to validate + * @returns true if all signatures are valid, false if any signature is invalid + */ +export const validateSignatures = (signatures: any[]): boolean => { + return signatures.every((signature) => validateSignature(signature)); +}; diff --git a/packages/adena-extension/src/common/validation/validation-signed-document.ts b/packages/adena-extension/src/common/validation/validation-signed-document.ts new file mode 100644 index 000000000..9f5f19202 --- /dev/null +++ b/packages/adena-extension/src/common/validation/validation-signed-document.ts @@ -0,0 +1,44 @@ +/** + * Validates basic fields of a signed document. + * Verifies that chain_id, account_number, sequence, and memo are all strings. + * + * @param signedDocument - The signed document object to validate + * @returns true if all basic fields are valid strings, false otherwise + */ +export const validateSignedDocumentFields = (signedDocument: any): boolean => { + return ( + typeof signedDocument.chain_id === 'string' && + typeof signedDocument.account_number === 'string' && + typeof signedDocument.sequence === 'string' && + typeof signedDocument.memo === 'string' + ); +}; + +/** + * Validates the fee structure of a signed document. + * Verifies that fee object exists, gas is a non-empty string, and amount is a non-empty array. + * + * @param fee - The fee object to validate + * @returns true if fee structure is valid, false otherwise + */ + +export const validateSignedDocumentFee = (fee: any): boolean => { + return ( + fee && + typeof fee.gas === 'string' && + Array.isArray(fee.amount) && + fee.gas.length > 0 && + fee.amount.length > 0 + ); +}; + +/** + * Validates the messages array of a signed document. + * Verifies that msgs is an array and contains at least one message. + * + * @param msgs - The messages array to validate + * @returns true if messages array is valid and not empty, false otherwise + */ +export const validateSignedDocumentMessages = (msgs: any): boolean => { + return Array.isArray(msgs) && msgs.length > 0; +}; diff --git a/packages/adena-extension/src/common/validation/validation-transaction.ts b/packages/adena-extension/src/common/validation/validation-transaction.ts index 382e116bf..279703103 100644 --- a/packages/adena-extension/src/common/validation/validation-transaction.ts +++ b/packages/adena-extension/src/common/validation/validation-transaction.ts @@ -37,14 +37,16 @@ export const validateInjectionDataWithAddress = ( export const validateInjectionTransactionType = (requestData: InjectionMessage): any => { const messageTypes = ['/bank.MsgSend', '/vm.m_call', '/vm.m_addpkg', '/vm.m_run']; - return requestData.data?.messages.every((message: any) => messageTypes.includes(message?.type)); + + const msgs = requestData.data?.messages || requestData.data?.msgs || []; + return msgs.every((message: any) => messageTypes.includes(message?.type)); }; export const validateInjectionTransactionMessageWithAddress = ( requestData: InjectionMessage, address: string, ): boolean => { - const messages = requestData.data?.messages; + const messages = requestData.data?.messages || requestData.data?.msgs || []; for (const message of messages) { let messageAddress = ''; switch (message?.type) { diff --git a/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.stories.tsx b/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.stories.tsx new file mode 100644 index 000000000..2d255a902 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.stories.tsx @@ -0,0 +1,33 @@ +import { ApproveSignedDocument, type ApproveSignedDocumentProps } from '.'; +import { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'components/approve/ApproveTransaction', + component: ApproveSignedDocument, +} as Meta; + +export const Default: StoryObj = { + args: { + domain: '', + loading: true, + logo: '', + title: 'Sign Transaction', + contracts: [ + { + type: '/vm.m_call', + function: 'GetBoardIDFromName', + value: '', + }, + ], + networkFee: { + amount: '0.0048', + denom: 'GNOT', + }, + transactionData: '', + opened: false, + onToggleTransactionData: action('openTransactionData'), + onClickConfirm: action('confirm'), + onClickCancel: action('cancel'), + }, +}; diff --git a/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.styles.ts b/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.styles.ts new file mode 100644 index 000000000..0afc8c044 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/approve-signed-document/approve-signed-document.styles.ts @@ -0,0 +1,192 @@ +import styled from 'styled-components'; + +import mixins from '@styles/mixins'; +import { fonts, getTheme } from '@styles/theme'; + +export const ApproveSignedDocumentSignerWrapper = styled.div` + ${mixins.flex({ justify: 'flex-start' })}; + padding: 24px 20px; +`; + +export const ApproveSignedDocumentWrapper = styled.div<{ isErrorNetworkFee: boolean }>` + ${mixins.flex({ justify: 'flex-start' })}; + width: 100%; + padding: 0 20px; + padding-bottom: 96px; + align-self: center; + + .row { + ${mixins.flex({ direction: 'row' })}; + position: relative; + padding: 10px 18px; + justify-content: space-between; + border-bottom: 2px solid ${getTheme('neutral', '_8')}; + ${fonts.body1Reg}; + + &:last-child { + border-bottom: none; + } + + .key { + display: inline-flex; + width: fit-content; + flex-shrink: 0; + color: ${getTheme('neutral', 'a')}; + } + + .value { + display: block; + max-width: 204px; + text-align: right; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .main-title { + text-overflow: ellipsis; + margin-top: 24px; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: center; + } + + .logo-wrapper { + margin: 24px auto; + width: 100%; + height: auto; + text-align: center; + + img { + width: 80px; + height: 80px; + } + } + + .domain-wrapper { + ${mixins.flex({ direction: 'row', align: 'center', justify: 'center' })}; + width: 100%; + min-height: 41px; + border-radius: 24px; + padding: 10px 18px; + margin: 24px auto 12px auto; + gap: 7px; + background-color: ${getTheme('neutral', '_9')}; + ${fonts.body2Reg}; + + .logo { + width: 20px; + height: 20px; + border-radius: 50%; + } + } + + .info-table { + width: 100%; + height: auto; + border-radius: 18px; + margin-bottom: 8px; + background-color: ${getTheme('neutral', '_9')}; + } + + .memo-wrapper { + width: 100%; + min-height: 48px; + border-radius: 30px; + padding: 10px 18px; + margin-bottom: 8px; + background-color: ${getTheme('neutral', '_9')}; + border: 1px solid ${getTheme('neutral', '_8')}; + gap: 10px; + ${fonts.body2Reg}; + + span.value { + display: block; + width: 100%; + max-width: 100%; + height: auto; + word-break: break-all; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + input.value { + display: block; + width: 100%; + max-width: 100%; + height: auto; + + &::placeholder { + color: ${getTheme('neutral', 'a')}; + } + } + + &.editable { + border: 1px solid ${getTheme('neutral', '_7')}; + } + } + + .fee-amount-wrapper { + ${mixins.flex({ justify: 'flex-start' })}; + width: 100%; + gap: 8px; + margin-bottom: 8px; + } + + .error-message { + position: relative; + width: 100%; + padding: 0 16px; + ${fonts.captionReg}; + color: ${getTheme('red', '_5')}; + white-space: pre-line; + } + + .transaction-data-wrapper { + width: 100%; + ${fonts.body2Reg}; + ${mixins.flex()}; + + .visible-button { + color: ${getTheme('neutral', 'a')}; + height: fit-content; + margin-bottom: 5px; + + img { + margin-left: 3px; + } + } + .textarea-wrapper { + width: 100%; + height: 200px; + border-radius: 24px; + background-color: ${getTheme('neutral', '_9')}; + border: 1px solid ${getTheme('neutral', '_7')}; + padding: 12px 16px; + } + .raw-info-textarea { + width: 100%; + height: 100%; + overflow: auto; + ${fonts.body2Reg}; + resize: none; + } + .raw-info-textarea::-webkit-scrollbar { + width: 2px; + padding: 1px 1px 1px 0px; + margin-right: 10px; + } + + .raw-info-textarea::-webkit-scrollbar-thumb { + background-color: darkgrey; + } + + .raw-info-textarea::-webkit-resizer { + display: none !important; + } + + margin-bottom: 20px; + } +`; diff --git a/packages/adena-extension/src/components/molecules/approve-signed-document/index.tsx b/packages/adena-extension/src/components/molecules/approve-signed-document/index.tsx new file mode 100644 index 000000000..bcf578833 --- /dev/null +++ b/packages/adena-extension/src/components/molecules/approve-signed-document/index.tsx @@ -0,0 +1,259 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Button, Text } from '@components/atoms'; +import { BottomFixedLoadingButtonGroup } from '@components/molecules'; + +import IconArraowDown from '@assets/arrowS-down-gray.svg'; +import IconArraowUp from '@assets/arrowS-up-gray.svg'; +import UnknownLogo from '@assets/common-unknown-logo.svg'; +import { GnoArgumentInfo } from '@inject/message/methods/gno-connect'; +import { ContractMessage } from '@inject/types'; +import { NetworkFee as NetworkFeeType } from '@types'; +import { ApproveTransactionLoading } from '../approve-transaction-loading'; +import ApproveTransactionMessageBox from '../approve-transaction-message-box/approve-transaction-message-box'; +import DocumentSigner from '../document-signer/document-signer'; +import NetworkFee from '../network-fee/network-fee'; +import { + ApproveSignedDocumentSignerWrapper, + ApproveSignedDocumentWrapper, +} from './approve-signed-document.styles'; +import { Signature } from '@adena-wallet/sdk'; +import DocumentSignerListScreen from '@components/pages/document-signer-list-screen/document-signer-list-screen'; +import { useSignerAddresses } from '@hooks/wallet/use-signer-addresses'; + +export interface ApproveSignedDocumentProps { + loading: boolean; + title: string; + logo: string; + domain: string; + contracts: { + type: string; + function: string; + value: string; + }[]; + signatures: Signature[]; + hasSignatures: boolean; + memo: string; + hasMemo: boolean; + currentBalance?: number; + isErrorNetworkFee?: boolean; + isNetworkFeeLoading: boolean; + networkFee: NetworkFeeType | null; + transactionData: string; + opened: boolean; + argumentInfos?: GnoArgumentInfo[]; + processing: boolean; + done: boolean; + transactionMessages: ContractMessage[]; + maxDepositAmount?: number; + changeTransactionMessages: (messages: ContractMessage[]) => void; + changeMemo: (memo: string) => void; + openScannerLink: (path: string, parameters?: { [key in string]: string }) => void; + onToggleTransactionData: (opened: boolean) => void; + onResponse: () => void; + onTimeout: () => void; + onClickConfirm: () => void; + onClickCancel: () => void; +} + +export const ApproveSignedDocument: React.FC = ({ + loading, + title, + logo, + domain, + signatures, + hasSignatures, + transactionMessages, + memo, + hasMemo, + networkFee, + isErrorNetworkFee, + isNetworkFeeLoading, + transactionData, + opened, + processing, + done, + argumentInfos, + changeTransactionMessages, + changeMemo, + onToggleTransactionData, + onResponse, + onClickConfirm, + onClickCancel, + openScannerLink, +}) => { + const [openedSigners, setOpenedSigners] = useState(false); + + const { signerAddresses } = useSignerAddresses(signatures); + + const disabledApprove = useMemo(() => { + if (isNetworkFeeLoading) { + return true; + } + + if (isErrorNetworkFee) { + return true; + } + + return Number(networkFee?.amount || 0) <= 0; + }, [isErrorNetworkFee, isNetworkFeeLoading, networkFee]); + + const networkFeeErrorMessage = useMemo(() => { + if (isErrorNetworkFee) { + return 'Insufficient network fee'; + } + + return ''; + }, [isErrorNetworkFee]); + + const onChangeMemo = useCallback( + (e: React.ChangeEvent) => { + if (hasMemo) { + return; + } + + const value = e.target.value; + changeMemo(value); + }, + [hasMemo, changeMemo], + ); + + const onClickConfirmButton = useCallback(() => { + if (disabledApprove) { + return; + } + + onClickConfirm(); + }, [onClickConfirm, disabledApprove]); + + const onClickSignersSetting = useCallback(() => { + setOpenedSigners(true); + }, []); + + const onClickSignersBack = useCallback(() => { + setOpenedSigners(false); + }, []); + + useEffect(() => { + if (done) { + onResponse(); + } + }, [done, onResponse]); + + if (loading) { + return ; + } + + if (openedSigners) { + return ( + + + + ); + } + + return ( + + + {title} + + +
+ logo img + {domain} +
+ + + +
+ Memo: + {hasMemo ? ( + {memo} + ) : ( + + )} +
+ + {hasSignatures && ( +
+ +
+ )} + +
+ +
+ +
+ + + {opened && ( +
+