diff --git a/packages/apps/dev-wallet/src/App/routes.tsx b/packages/apps/dev-wallet/src/App/routes.tsx index 529b002d1b..93a22926c3 100644 --- a/packages/apps/dev-wallet/src/App/routes.tsx +++ b/packages/apps/dev-wallet/src/App/routes.tsx @@ -29,6 +29,7 @@ import { ImportData } from '@/pages/settings/import-data/import-data'; import { RevealPhrase } from '@/pages/settings/reveal-phrase/reveal-phrase'; import { Settings } from '@/pages/settings/settings'; import { SignatureBuilder } from '@/pages/signature-builder/signature-builder'; +import { TransactionGroupPage } from '@/pages/transaction-group/TransactionGroup'; import { TransactionPage } from '@/pages/transaction/Transaction'; import { Transactions } from '@/pages/transactions/transactions'; import { Transfer } from '@/pages/transfer/transfer'; @@ -120,6 +121,10 @@ export const Routes: FC = () => { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx b/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx index 8de06df638..744af6cf8e 100644 --- a/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx +++ b/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx @@ -74,6 +74,7 @@ export const UnlockPrompt: React.FC<{ type="password" {...register('password')} label="Password" + autoFocus /> )} {storePassword && ( diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/ExistingTransactionList.tsx b/packages/apps/dev-wallet/src/pages/signature-builder/ExistingTransactionList.tsx new file mode 100644 index 0000000000..18ca6399b3 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/signature-builder/ExistingTransactionList.tsx @@ -0,0 +1,59 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; +import { usePatchedNavigate } from '@/utils/usePatchedNavigate'; +import { + MonoCheck, + MonoRemoveCircleOutline, + MonoSignature, +} from '@kadena/kode-icons/system'; +import { Box, Stack, Text } from '@kadena/kode-ui'; +import React from 'react'; +import { TxTileGeneric } from '../transaction-group/components/TxTileGeneric'; +import { successClass } from '../transaction-group/components/style.css'; + +interface ExistingTransactionListProps { + transactions: ITransaction[]; + onRemove: (tx: ITransaction) => void; +} + +export const ExistingTransactionList: React.FC< + ExistingTransactionListProps +> = ({ transactions, onRemove }) => { + const navigate = usePatchedNavigate(); + + if (transactions.length === 0) return null; + + return ( + + {transactions.map((tx) => ( + ( + + + + Already present in wallet + + + ), + ]} + buttons={[ + { + label: 'Discard', + Icon: () => , + onClick: () => onRemove(tx), + position: 'flex-start', + }, + { + label: 'Open', + onClick: () => navigate(`/transaction-group/${tx.groupId}`), + Icon: () => , + position: 'flex-end', + }, + ]} + /> + ))} + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/TransactionFileUpload.tsx b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionFileUpload.tsx new file mode 100644 index 0000000000..c98db4445e --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionFileUpload.tsx @@ -0,0 +1,43 @@ +import { browse, readContent } from '@/utils/select-file'; +import { Box, Button } from '@kadena/kode-ui'; +import yaml from 'js-yaml'; +import React from 'react'; + +interface TransactionFileUploadProps { + onError: (message?: string) => void; + onProcess: (data: string) => void; +} + +export const TransactionFileUpload: React.FC = ({ + onError, + onProcess, +}) => { + const handleFile = async () => { + onError(undefined); + const files = await browse(true); + if (files && files instanceof FileList) { + await Promise.all( + Array.from(files).map(async (file) => { + const content = await readContent(file); + try { + // Attempt to load as YAML (which can also parse valid JSON) + yaml.load(content); + + // If successful, we pass the raw content along for processing + onProcess(content); + } catch (e) { + onError('Invalid file format, unable to parse as YAML/JSON'); + } + }), + ); + } + }; + + return ( + + + Add From File(s) + + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/TransactionInputArea.tsx b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionInputArea.tsx new file mode 100644 index 0000000000..c71f6e0418 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionInputArea.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Box, Notification } from '@kadena/kode-ui'; +import classNames from 'classnames'; +import { codeArea } from './style.css'; // Use your existing style + +interface TransactionInputAreaProps { + input: string; + error?: string; + onChange: (val: string) => void; +} + +export const TransactionInputArea: React.FC = ({ + input, + error, + onChange, +}) => { + return ( + + onChange(e.target.value)} + /> + {error && ( + + {error} + + )} + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/TransactionList.tsx b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionList.tsx new file mode 100644 index 0000000000..c0b2f0de9d --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/signature-builder/TransactionList.tsx @@ -0,0 +1,41 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; +import { MonoRemoveCircleOutline } from '@kadena/kode-icons/system'; +import { Box, Stack, Text } from '@kadena/kode-ui'; +import React from 'react'; +import { TxTileGeneric } from '../transaction-group/components/TxTileGeneric'; + +interface TransactionListProps { + transactions: ITransaction[]; + onRemove: (tx: ITransaction) => void; + title?: string; +} + +export const TransactionList: React.FC = ({ + transactions, + onRemove, + title = 'Transactions to review', +}) => { + if (transactions.length === 0) return null; + + return ( + + + {title} + {transactions.map((tx) => ( + , + onClick: () => onRemove(tx), + position: 'flex-start', + }, + ]} + /> + ))} + + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/signature-builder.tsx b/packages/apps/dev-wallet/src/pages/signature-builder/signature-builder.tsx index a2c0c0958b..cabb490974 100644 --- a/packages/apps/dev-wallet/src/pages/signature-builder/signature-builder.tsx +++ b/packages/apps/dev-wallet/src/pages/signature-builder/signature-builder.tsx @@ -1,184 +1,27 @@ -import { - addSignatures, - createTransaction, - IPactCommand, - IPartialPactCommand, - ISigningRequest, - IUnsignedCommand, -} from '@kadena/client'; - import { SideBarBreadcrumbs } from '@/Components/SideBarBreadcrumbs/SideBarBreadcrumbs'; -import { transactionRepository } from '@/modules/transaction/transaction.repository'; -import * as transactionService from '@/modules/transaction/transaction.service'; -import { useWallet } from '@/modules/wallet/wallet.hook'; -import { normalizeTx } from '@/utils/normalizeSigs'; -import { - determineSchema, - RequestScheme, - signingRequestToPactCommand, -} from '@/utils/transaction-scheme'; -import { usePatchedNavigate } from '@/utils/usePatchedNavigate'; -import { base64UrlDecodeArr } from '@kadena/cryptography-utils'; import { MonoDashboardCustomize } from '@kadena/kode-icons/system'; -import { - Box, - Button, - Heading, - Notification, - Stack, - Text, -} from '@kadena/kode-ui'; +import { Box, Button, Heading, Notification, Stack } from '@kadena/kode-ui'; import { SideBarBreadcrumbsItem } from '@kadena/kode-ui/patterns'; -import { execCodeParser } from '@kadena/pactjs-generator'; -import classNames from 'classnames'; -import yaml from 'js-yaml'; -import { useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { codeArea } from './style.css'; -const getTxFromUrlHash = () => - window.location.hash ? window.location.hash.substring(1) : undefined; +import { ExistingTransactionList } from './ExistingTransactionList'; +import { TransactionFileUpload } from './TransactionFileUpload'; +import { TransactionInputArea } from './TransactionInputArea'; +import { TransactionList } from './TransactionList'; +import { useSignatureBuilder } from './useSignatureBuilder'; export function SignatureBuilder() { - const [searchParams] = useSearchParams(); - const urlTransaction = searchParams.get('transaction') || getTxFromUrlHash(); - const [error, setError] = useState(); - const [schema, setSchema] = useState(); - const [input, setInput] = useState(''); - const [pactCommand, setPactCommand] = useState(); - const [unsignedTx, setUnsignedTx] = useState(); - const [signed, setSignedTx] = useState(); - const [capsWithoutSigners, setCapsWithoutSigners] = useState< - ISigningRequest['caps'] - >([]); - const { profile, activeNetwork, networks, setActiveNetwork } = useWallet(); - const navigate = usePatchedNavigate(); - - const exec = - pactCommand && pactCommand.payload && 'exec' in pactCommand.payload - ? pactCommand.payload.exec - : { code: null, data: {} }; - - const parsedCode = useMemo( - () => (exec.code ? execCodeParser(exec.code) : undefined), - [exec.code], - ); - - console.log('parsedCode', { - parsedCode, - capsWithoutSigners, - signed, - setSignedTx, - }); - - useEffect(() => { - if (urlTransaction) { - const data = new TextDecoder().decode(base64UrlDecodeArr(urlTransaction)); - setInput(data); - processSig(data); - } - }, [urlTransaction]); - - function processSig(inputData: string) { - setInput(inputData); - let schema = determineSchema(inputData); - if (schema === 'base64') { - inputData = Buffer.from(inputData, 'base64').toString(); - schema = determineSchema(inputData); - setInput(inputData); - } - - switch (schema) { - case 'quickSignRequest': { - const parsed = yaml.load(inputData) as IUnsignedCommand; - setPactCommand(JSON.parse(parsed.cmd)); - setUnsignedTx(normalizeTx(parsed)); - setCapsWithoutSigners([]); - break; - } - case 'PactCommand': { - const parsed = yaml.load(inputData) as IPartialPactCommand; - setPactCommand(parsed); - const tx = createTransaction(parsed); - setUnsignedTx(normalizeTx(tx)); - setCapsWithoutSigners([]); - break; - } - case 'signingRequest': { - const parsed = yaml.load(inputData) as ISigningRequest; - const pactCommand = signingRequestToPactCommand(parsed); - setPactCommand(pactCommand); - setCapsWithoutSigners(parsed.caps); - setUnsignedTx(undefined); - break; - } - default: - setPactCommand(undefined); - setUnsignedTx(undefined); - setCapsWithoutSigners([]); - break; - } - setSchema(schema); - } - - const reviewTransaction = async () => { - if (!unsignedTx || !profile || !activeNetwork) return; - const command: IPactCommand = JSON.parse(unsignedTx.cmd); - let networkUUID = activeNetwork.uuid; - if (command.networkId && activeNetwork.networkId !== command.networkId) { - const network = networks.filter( - ({ networkId }) => networkId === command.networkId, - ); - if (network.length === 0) { - setError( - command.networkId === 'mainnet01' - ? 'MANNET_IS_DISABLED: Mainnet is disabled since the wallet is not fully tested; you can try other networks' - : 'NETWORK_NOT_FOUND: This network is not found', - ); - throw new Error('Network not found'); - } - if (network.length > 1) { - // TODO: open a dialog to select network - } - networkUUID = network[0].uuid; - // switch network - setActiveNetwork(network[0]); - } - const groupId = crypto.randomUUID(); - - // check if transaction already exists - const tx = await transactionRepository.getTransactionByHashNetworkProfile( - profile.uuid, - networkUUID, - unsignedTx.hash, - ); - - if (tx) { - if (unsignedTx.sigs && unsignedTx.sigs.length > 0) { - const updatedTx = addSignatures( - tx, - ...(unsignedTx.sigs.filter((item) => item && item.sig) as Array<{ - sig: string; - pubKey?: string; - }>), - ); - await transactionRepository.updateTransaction({ - ...tx, - sigs: updatedTx.sigs, - }); - } - navigate(`/transaction/${tx.groupId}`); - return; - } - - await transactionService.addTransaction({ - transaction: unsignedTx, - profileId: profile.uuid, - networkUUID: networkUUID, - groupId, - }); - navigate(`/transaction/${groupId}`); - }; + const { + input, + schema, + error, + transactions, + existingTransactions, + canReviewTransactions, + processSigData, + setInput, + removeTransaction, + reviewTransaction, + } = useSignatureBuilder(); return ( <> @@ -188,35 +31,52 @@ export function SignatureBuilder() { - - Paste SigData, CommandSigData, or Payload - { - e.preventDefault(); - setError(undefined); - processSig(e.target.value); + + + Paste or Upload SigData, CommandSigData, or Payload + + + { + setInput(val); + processSigData(val); }} /> - {schema && {`Schema: ${schema}`}} + + { + console.error(message); + }} + onProcess={(data) => { + // Directly process the file content as input + processSigData(data); + setInput(data); + }} + /> + {schema === 'signingRequest' && ( - SigningRequest is not supported yet, We are working on it. - - )} - {error && ( - - {error} + SigningRequest is not supported yet. We are working on it. )} - + + + + {canReviewTransactions && ( - {['PactCommand', 'quickSignRequest'].includes(schema!) && ( - Review Transaction - )} + Review Transaction(s) - + )} + + > ); diff --git a/packages/apps/dev-wallet/src/pages/signature-builder/useSignatureBuilder.ts b/packages/apps/dev-wallet/src/pages/signature-builder/useSignatureBuilder.ts new file mode 100644 index 0000000000..578e367ee4 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/signature-builder/useSignatureBuilder.ts @@ -0,0 +1,230 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { base64UrlDecodeArr } from '@kadena/cryptography-utils'; +import yaml from 'js-yaml'; +import { + addSignatures, + createTransaction, + IPactCommand, + IPartialPactCommand, + IUnsignedCommand, + ISigningRequest, +} from '@kadena/client'; +import { determineSchema, RequestScheme, signingRequestToPactCommand } from '@/utils/transaction-scheme'; +import { normalizeTx } from '@/utils/normalizeSigs'; +import { execCodeParser } from '@kadena/pactjs-generator'; + +import { ITransaction, transactionRepository } from '@/modules/transaction/transaction.repository'; +import * as transactionService from '@/modules/transaction/transaction.service'; +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { usePatchedNavigate } from '@/utils/usePatchedNavigate'; + +function getTxFromUrlHash() { + return window.location.hash ? window.location.hash.substring(1) : undefined; +} + +interface UseSignatureBuilderReturn { + input: string; + schema?: RequestScheme; + error?: string; + transactions: ITransaction[]; + existingTransactions: ITransaction[]; + parsedCode?: ReturnType; + groupId: string; + canReviewTransactions: boolean; + setInput: (val: string) => void; + processSigData: (inputData: string) => void; + removeTransaction: (tx: ITransaction) => Promise; + reviewTransaction: () => void; +} + +export const useSignatureBuilder = (): UseSignatureBuilderReturn => { + const [searchParams] = useSearchParams(); + const urlTransaction = searchParams.get('transaction') || getTxFromUrlHash(); + + const [input, setInput] = useState(''); + const [error, setError] = useState(); + const [schema, setSchema] = useState(); + const [pactCommand, setPactCommand] = useState(); + const [unsignedTx, setUnsignedTx] = useState(); + const [capsWithoutSigners, setCapsWithoutSigners] = useState([]); + const [groupId] = useState(crypto.randomUUID()); + const [transactions, setTransactions] = useState([]); + const [existingTransactions, setExistingTransactions] = useState([]); + + const { profile, activeNetwork, networks, setActiveNetwork } = useWallet(); + const navigate = usePatchedNavigate(); + + const exec = useMemo(() => { + if (pactCommand && pactCommand.payload && 'exec' in pactCommand.payload) { + return pactCommand.payload.exec; + } + return { code: null, data: {} }; + }, [pactCommand]); + + const parsedCode = useMemo(() => (exec.code ? execCodeParser(exec.code) : undefined), [exec.code]); + + useEffect(() => { + if (urlTransaction) { + const data = new TextDecoder().decode(base64UrlDecodeArr(urlTransaction)); + setInput(data); + processSigData(data); + addTransactionToGroup(groupId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlTransaction]); + + useEffect(() => { + (async function addTransaction() { + if (unsignedTx && profile) { + await addTransactionToGroup(groupId); + setTransactions(await transactionRepository.getTransactionsByGroup(groupId, profile.uuid)); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [unsignedTx]); + + async function addTransactionToGroup(groupId: string) { + if (!unsignedTx || !profile || !activeNetwork) return; + + const command: IPactCommand = JSON.parse(unsignedTx.cmd); + let networkUUID = activeNetwork.uuid; + + // Handle network switching if necessary + if (command.networkId && activeNetwork.networkId !== command.networkId) { + const network = networks.filter(({ networkId }) => networkId === command.networkId); + if (network.length === 0) { + setError( + command.networkId === 'mainnet01' + ? 'MAINNET_IS_DISABLED: Mainnet is disabled.' + : 'NETWORK_NOT_FOUND: This network is not found', + ); + throw new Error('Network not found'); + } + networkUUID = network[0].uuid; + setActiveNetwork(network[0]); + } + + // Check if transaction already exists + const tx = await transactionRepository.getTransactionByHashNetworkProfile( + profile.uuid, + networkUUID, + unsignedTx.hash, + ); + + if (tx) { + // Add signatures if available + if (unsignedTx.sigs && unsignedTx.sigs.length > 0) { + const updatedTx = addSignatures( + tx, + ...(unsignedTx.sigs.filter((item) => item && item.sig) as Array<{ sig: string; pubKey?: string }>), + ); + + await transactionRepository.updateTransaction({ + ...tx, + sigs: updatedTx.sigs, + }); + + if (existingTransactions.find((item) => item.uuid === tx.uuid) === undefined) { + setExistingTransactions((prev) => [...prev, tx]); + } + } + resetInput(); + return; + } + + // Add new transaction + await transactionService.addTransaction({ + transaction: unsignedTx, + profileId: profile.uuid, + networkUUID: networkUUID, + groupId, + }); + resetInput(); + } + + function resetInput() { + setPactCommand(undefined); + setUnsignedTx(undefined); + setCapsWithoutSigners([]); + setInput(''); + } + + function processSigData(inputData: string) { + setError(undefined); + let data = inputData; + let detectedSchema = determineSchema(inputData); + + if (detectedSchema === 'base64') { + data = Buffer.from(inputData, 'base64').toString(); + detectedSchema = determineSchema(data); + setInput(data); + } + + switch (detectedSchema) { + case 'quickSignRequest': { + const parsed = yaml.load(data) as IUnsignedCommand; + setPactCommand(JSON.parse(parsed.cmd)); + setUnsignedTx(normalizeTx(parsed)); + setCapsWithoutSigners([]); + break; + } + case 'PactCommand': { + const parsed = yaml.load(data) as IPartialPactCommand; + setPactCommand(parsed); + const tx = createTransaction(parsed); + setUnsignedTx(normalizeTx(tx)); + setCapsWithoutSigners([]); + break; + } + case 'signingRequest': { + const parsed = yaml.load(data) as ISigningRequest; + const pactCommand = signingRequestToPactCommand(parsed); + setPactCommand(pactCommand); + setCapsWithoutSigners(parsed.caps); + setUnsignedTx(undefined); + break; + } + default: + setPactCommand(undefined); + setUnsignedTx(undefined); + setCapsWithoutSigners([]); + break; + } + setSchema(detectedSchema); + } + + const removeTransaction = async (tx: ITransaction) => { + if (!profile) return; + + if (existingTransactions.find((item) => item.uuid === tx.uuid) !== undefined) { + setExistingTransactions((prev) => prev.filter((item) => item.uuid !== tx.uuid)); + } + + if (transactions.find((item) => item.uuid === tx.uuid) !== undefined) { + await transactionRepository.deleteTransaction(tx.uuid); + setTransactions(await transactionRepository.getTransactionsByGroup(groupId, profile.uuid)); + } + }; + + const reviewTransaction = () => { + navigate(`/transaction-group/${groupId}`); + }; + + const canReviewTransactions = transactions.length > 0 && ['PactCommand', 'quickSignRequest'].includes(schema!); + + return { + input, + schema, + error, + transactions, + existingTransactions, + parsedCode, + groupId, + canReviewTransactions, + setInput, + processSigData, + removeTransaction, + reviewTransaction, + }; +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/TransactionGroup.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/TransactionGroup.tsx new file mode 100644 index 0000000000..ed564d39f3 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/TransactionGroup.tsx @@ -0,0 +1,80 @@ +import { + ITransaction, + transactionRepository, +} from '@/modules/transaction/transaction.repository'; + +import { SideBarBreadcrumbs } from '@/Components/SideBarBreadcrumbs/SideBarBreadcrumbs'; +import { useRequests } from '@/modules/communication/communication.provider'; +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { usePatchedNavigate } from '@/utils/usePatchedNavigate'; +import { MonoSwapHoriz } from '@kadena/kode-icons/system'; +import { Heading, Stack, Text } from '@kadena/kode-ui'; +import { SideBarBreadcrumbsItem } from '@kadena/kode-ui/patterns'; +import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { TxList } from './components/TxList'; + +export const TransactionGroupPage = () => { + const navigate = usePatchedNavigate(); + const { groupId } = useParams(); + const { profile } = useWallet(); + const [searchParam] = useSearchParams(); + const requestId = searchParam.get('request'); + const requests = useRequests(); + const [txs = [], setTxList] = useState(); + + useEffect(() => { + const run = async () => { + if (profile?.uuid && groupId) { + const list = await transactionRepository.getTransactionsByGroup( + groupId, + profile.uuid, + ); + if (!list || list.length === 0) { + navigate('/transactions'); + } + setTxList(list); + } + }; + run(); + }, [groupId, navigate, profile?.uuid]); + + return ( + <> + }> + + Transactions + + + Transaction Group + + + + + + Transactions + {txs.length === 0 && No transactions} + {txs.length >= 2 && ( + This is a group of {txs.length} Transactions + )} + + { + console.log('done'); + }} + txIds={txs.map((tx) => tx.uuid)} + showExpanded={txs.length === 1} + onSign={(tx) => { + if (requestId) { + const request = requests.get(requestId); + if (request) { + console.log('resolving request', request); + request.resolve({ status: 'signed', transaction: tx }); + } + } + }} + /> + + > + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/CommandView.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/CommandView.tsx new file mode 100644 index 0000000000..eb718f806b --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/CommandView.tsx @@ -0,0 +1,233 @@ +import { CopyButton } from '@/Components/CopyButton/CopyButton'; +import { ITransaction } from '@/modules/transaction/transaction.repository'; +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { shorten, toISOLocalDateTime } from '@/utils/helpers'; +import { shortenPactCode } from '@/utils/parsedCodeToPact'; +import { IPactCommand } from '@kadena/client'; +import { MonoTextSnippet } from '@kadena/kode-icons/system'; +import { Button, Heading, Notification, Stack, Text } from '@kadena/kode-ui'; +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { Label, Value } from './helpers'; +import { RenderSigner } from './Signer'; +import { cardClass, codeClass, textEllipsis } from './style.css'; + +export function CommandView({ + transaction, + onSign, +}: { + transaction: ITransaction; + onSign: (sig: ITransaction['sigs']) => void; +}) { + const { getPublicKeyData } = useWallet(); + const command: IPactCommand = useMemo( + () => JSON.parse(transaction.cmd), + [transaction.cmd], + ); + const signers = useMemo( + () => + command.signers.map((signer) => { + const info = getPublicKeyData(signer.pubKey); + return { + ...signer, + info, + }; + }), + [command, getPublicKeyData], + ); + + const externalSigners = signers.filter((signer) => !signer.info); + const internalSigners = signers.filter((signer) => signer.info); + const [showShortenCode, setShowShortenCode] = useState(true); + return ( + + + hash (request-key) + {transaction.hash} + + {'exec' in command.payload && ( + <> + + + Code + setShowShortenCode(!showShortenCode)} + variant={'transparent'} + isCompact + startVisual={} + /> + + + {showShortenCode ? ( + + {shortenPactCode(command.payload.exec.code)} + + ) : ( + command.payload.exec.code + )} + + + {Object.keys(command.payload.exec.data).length > 0 && ( + + Data + + {JSON.stringify(command.payload.exec.data, null, 2)} + + + )} + > + )} + {'cont' in command.payload && ( + <> + + Continuation + + {command.payload.cont.pactId}- step( + {command.payload.cont.step}) + + + {Object.keys(command.payload.cont.data || {}).length > 0 && ( + + Data + + {JSON.stringify(command.payload.cont.data, null, 2)} + + + )} + {command.payload.cont.proof && ( + + + Proof + + + + {shorten(command.payload.cont.proof, 40)} + + + )} + > + )} + + Transaction Metadata + + + Network + {command.networkId} + + + Chain + {command.meta.chainId} + + + Creation time + + {command.meta.creationTime} ( + {toISOLocalDateTime(command.meta.creationTime! * 1000)}) + + + + TTL + + {command.meta.ttl} ( + {toISOLocalDateTime( + (command.meta.ttl! + command.meta.creationTime!) * 1000, + )} + ) + + + + Nonce + {command.nonce} + + + + + Gas Info + + + Gas Payer + {command.meta.sender} + + + Gas Price + {command.meta.gasPrice} + + + Gas Limit + {command.meta.gasLimit} + + + Max Gas Cost + {command.meta.gasLimit! * command.meta.gasPrice!} KDA + + + + + + Your Signatures + {internalSigners.length === 0 && ( + + Nothing to sign by you + + )} + {internalSigners.map((signer) => { + return ( + + + + ); + })} + + {externalSigners.length > 0 && ( + + External Signers + {externalSigners.map((signer) => { + return ( + + + + ); + })} + + )} + + + ); +} diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/ExpandedTransaction.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/ExpandedTransaction.tsx new file mode 100644 index 0000000000..4c814693dd --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/ExpandedTransaction.tsx @@ -0,0 +1,309 @@ +import { ICommand, IUnsignedCommand } from '@kadena/client'; +import { + Button, + ContextMenu, + ContextMenuItem, + DialogContent, + DialogHeader, + Heading, + Notification, + Stack, + TabItem, + Tabs, + Tooltip, +} from '@kadena/kode-ui'; +import yaml from 'js-yaml'; + +import { codeClass, txDetailsClass, txExpandedWrapper } from './style.css.ts'; + +import { CopyButton } from '@/Components/CopyButton/CopyButton.tsx'; +import { ITransaction } from '@/modules/transaction/transaction.repository.ts'; +import { useWallet } from '@/modules/wallet/wallet.hook.tsx'; +import { panelClass } from '@/pages/home/style.css.ts'; + +import { shorten } from '@/utils/helpers.ts'; +import { normalizeTx } from '@/utils/normalizeSigs.ts'; +import { base64UrlEncodeArr } from '@kadena/cryptography-utils'; +import { + MonoContentCopy, + MonoMoreVert, + MonoShare, +} from '@kadena/kode-icons/system'; +import classNames from 'classnames'; +import { useState } from 'react'; +import { CommandView } from './CommandView.tsx'; +import { statusPassed, TxPipeLine } from './TxPipeLine.tsx'; + +export function ExpandedTransaction({ + transaction, + contTx, + onSign, + sendDisabled, + onSubmit, + showTitle, + isDialog, + onPreflight, +}: { + transaction: ITransaction; + contTx?: ITransaction; + onSign: (sig: ITransaction['sigs']) => void; + onSubmit: (skipPreflight?: boolean) => Promise; + onPreflight: () => Promise; + sendDisabled?: boolean; + showTitle?: boolean; + isDialog?: boolean; +}) { + const { sign } = useWallet(); + const [showShareTooltip, setShowShareTooltip] = useState(false); + + const copyTransactionAs = + (format: 'json' | 'yaml', legacySig = false) => + () => { + const tx = { + hash: transaction.hash, + cmd: transaction.cmd, + sigs: transaction.sigs, + }; + const transactionData = legacySig ? normalizeTx(tx) : tx; + + let formattedData: string; + if (format === 'json') { + formattedData = JSON.stringify(transactionData, null, 2); + } else { + formattedData = yaml.dump(transactionData); + } + + navigator.clipboard.writeText(formattedData); + }; + + const signAll = async () => { + const signedTx = (await sign(transaction)) as IUnsignedCommand | ICommand; + onSign(signedTx.sigs); + }; + const txCommand = { + hash: transaction.hash, + cmd: transaction.cmd, + sigs: transaction.sigs, + }; + const Title = isDialog ? DialogHeader : Stack; + const Content = isDialog ? DialogContent : Stack; + return ( + <> + + + {showTitle && View Transaction} + + + + + + + Tx Status + + + + + {statusPassed(transaction.status, 'success') && + (!transaction.continuation?.autoContinue || + (contTx && statusPassed(contTx.status, 'success'))) && ( + + + Transaction is successful + + + )} + {transaction.status === 'failure' && + (!transaction.continuation?.autoContinue || + (contTx && contTx.status === 'failure')) && ( + + + Transaction is failed + + + )} + + + + + Command Details + + + } + variant="transparent" + onClick={() => { + const encodedTx = base64UrlEncodeArr( + new TextEncoder().encode( + JSON.stringify({ + hash: txCommand.hash, + cmd: txCommand.cmd, + sigs: txCommand.sigs, + }), + ), + ); + const baseUrl = `${window.location.protocol}//${window.location.host}`; + navigator.clipboard.writeText( + `${baseUrl}/sig-builder#${encodedTx}`, + ); + setShowShareTooltip(true); + setTimeout(() => setShowShareTooltip(false), 5000); + }} + isCompact + /> + + + } + variant="transparent" + isCompact + /> + } + > + } + onClick={copyTransactionAs('json')} + /> + } + onClick={copyTransactionAs('yaml')} + /> + } + onClick={copyTransactionAs('json', true)} + /> + } + onClick={copyTransactionAs('yaml', true)} + /> + + + + + + + + {transaction.preflight && + (( + + + + ) as any)} + + {transaction.request && ( + + + + )} + {'result' in transaction && transaction.result && ( + + + + )} + {transaction.continuation?.proof && ( + + + + )} + {contTx && [ + + + Command Details + + + , + contTx.preflight && ( + + + + ), + contTx.request && ( + + + + ), + 'result' in contTx && contTx.result && ( + + + + ), + ]} + + + + + > + ); +} + +const JsonView = ({ + title, + data, + shortening = 0, +}: { + title: string; + data: any; + shortening?: number; +}) => ( + + + + {title} + + + + {data && typeof data === 'object' + ? JSON.stringify(data, null, 2) + : shortening + ? shorten(data, shortening) + : data} + + + +); diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/Signer.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/Signer.tsx new file mode 100644 index 0000000000..fc0de27885 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/Signer.tsx @@ -0,0 +1,221 @@ +import { + addSignatures, + IPactCommand, + IUnsignedCommand, + parseAsPactValue, +} from '@kadena/client'; +import { Button, Heading, Stack, Text, TextareaField } from '@kadena/kode-ui'; +import { FC, PropsWithChildren } from 'react'; +import { + breakAllClass, + codeClass, + readyToSignClass, + signedClass, + tagClass, +} from './style.css.ts'; + +import { ITransaction } from '@/modules/transaction/transaction.repository.ts'; +import { useWallet } from '@/modules/wallet/wallet.hook.tsx'; +import { normalizeSigs } from '@/utils/normalizeSigs.ts'; +import { MonoContentCopy, MonoDelete } from '@kadena/kode-icons/system'; +import classNames from 'classnames'; + +import yaml from 'js-yaml'; +import { statusPassed } from './TxPipeLine.tsx'; + +const Value: FC> = ({ + children, + className, +}) => ( + + {children} + +); + +export const RenderSigner = ({ + transaction, + signer, + transactionStatus, + onSign, +}: { + transaction: IUnsignedCommand; + signer: IPactCommand['signers'][number]; + transactionStatus: ITransaction['status']; + onSign: (sig: ITransaction['sigs']) => void; +}) => { + const { getPublicKeyData, sign } = useWallet(); + const signature = transaction.sigs.find( + (sig) => sig?.pubKey === signer.pubKey && sig.sig, + )?.sig; + const info = getPublicKeyData(signer.pubKey); + return ( + <> + + Public Key + {signature && Signed} + {!signature && info && Owned} + {{info?.source ?? 'External'}} + + {signer.pubKey} + + + Sign for + {signer.clist && + signer.clist.map((cap) => ( + + + ( + {[ + cap.name, + ...cap.args.map((data) => + typeof data === 'number' ? data : parseAsPactValue(data), + ), + ].join(' ')} + ) + + + ))} + + {signature && ( + <> + + Signature + + { + const updatedSigs = transaction.sigs.map((sig) => + sig?.pubKey === signer.pubKey && sig.sig + ? { pubKey: sig.pubKey } + : sig, + ); + onSign(updatedSigs); + }} + > + + + } + onClick={() => { + navigator.clipboard.writeText( + JSON.stringify({ + sig: signature, + pubKey: signer.pubKey, + }), + ); + }} + /> + + + + {signature} + + > + )} + {!signature && info && ( + + { + const signed = (await sign(transaction, [ + signer.pubKey, + ])) as IUnsignedCommand; + onSign(signed.sigs ?? []); + }} + > + Sign + + + )} + {!signature && !info && ( + { + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + const signature = formData.get('signature') as string; + let sigObject; + if (!signature) return; + try { + const json: + | IUnsignedCommand + | { + pubKey: string; + sig?: string; + } = yaml.load(signature) as + | IUnsignedCommand + | { + pubKey: string; + sig?: string; + }; + + let sigs: Array<{ + sig?: string; + pubKey: string; + }> = []; + if ('sig' in json) { + if (!json.pubKey) { + json.pubKey = signer.pubKey; + } + sigs = [json]; + } else { + sigs = normalizeSigs(json as IUnsignedCommand); + } + + const extSignature = sigs.find( + (item) => item.pubKey === signer.pubKey, + ); + + if (!extSignature || !extSignature.sig) { + return; + } + sigObject = extSignature as { + sig: string; + pubKey: string; + }; + } catch (e) { + sigObject = { + sig: signature, + pubKey: signer.pubKey, + }; + } + // TODO: verify signature before adding it + const sigs = addSignatures(transaction, sigObject); + onSign(sigs.sigs); + }} + > + + + + + + Add External Signature + + + + )} + > + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/SubmittedStatus.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/SubmittedStatus.tsx new file mode 100644 index 0000000000..06ed1ff46d --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/SubmittedStatus.tsx @@ -0,0 +1,93 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; +import { MonoBrightness1 } from '@kadena/kode-icons/system'; +import { Button, Heading, Stack, Text } from '@kadena/kode-ui'; +import classNames from 'classnames'; +import { Value } from './helpers'; +import { + codeClass, + failureClass, + pendingClass, + pendingText, + successClass, +} from './style.css'; + +export function SubmittedStatus({ + transaction, +}: { + transaction: ITransaction; +}) { + if (!transaction.request?.requestKey) { + return Request Key not found; + } + if (transaction.preflight?.result.status !== 'success') { + return Preflight failed; + } + const status = transaction.status; + return ( + + Transaction Status + + Request Key + {transaction.request.requestKey} + + + Preflight Result + + + {JSON.stringify(transaction.preflight?.result.data, null, 2)} + + + + + Transaction Status + + {' '} + Transaction sent to mempool + + + {' '} + + Transaction successfully mined + + + + + {transaction.result && transaction.result.result.status === 'success' && ( + + Transaction Result + + {JSON.stringify(transaction.result.result.data, null, 2)} + + + )} + + {transaction.result && ( + { + navigator.clipboard.writeText(JSON.stringify(transaction.result)); + }} + > + Copy result + + )} + + {status === 'success' && transaction.result?.continuation && ( + + + {' '} + Transaction need continuation + + + create continuation + + + )} + + ); +} diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxContainer.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxContainer.tsx new file mode 100644 index 0000000000..eb07ac99c2 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxContainer.tsx @@ -0,0 +1,203 @@ +import { useSubscribe } from '@/modules/db/useSubscribe'; +import { + ITransaction, + transactionRepository, +} from '@/modules/transaction/transaction.repository'; +import * as transactionService from '@/modules/transaction/transaction.service'; +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { IUnsignedCommand } from '@kadena/client'; +import { Dialog } from '@kadena/kode-ui'; +import { isSignedCommand } from '@kadena/pactjs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ExpandedTransaction } from './ExpandedTransaction'; +import { containerClass } from './style.css'; +import { TxMinimized } from './TxMinimized'; +import { steps } from './TxPipeLine'; +import { TxTile } from './TxTile'; + +export const TxContainer = React.memo( + ({ + transaction, + as, + sendDisabled, + onUpdate, + onDone, + }: { + transaction: ITransaction; + as: 'tile' | 'expanded' | 'minimized'; + sendDisabled?: boolean; + onUpdate?: (tx: ITransaction) => void; + onDone?: (tx: ITransaction) => void; + }) => { + const [expandedModal, setExpandedModal] = useState(false); + const { sign, client } = useWallet(); + const syncing = useRef(false); + const doneRef = useRef(false); + + const localTransaction = useSubscribe( + 'transaction', + transaction.uuid, + ); + const contTx = useSubscribe( + 'transaction', + localTransaction?.continuation?.continuationTxId, + ); + + useEffect(() => { + if ( + localTransaction && + localTransaction.status === 'success' && + (!localTransaction.continuation?.autoContinue || + contTx?.status === 'success') + ) { + if (onDone && !doneRef.current) { + doneRef.current = true; + console.log('onDone', localTransaction); + onDone(localTransaction); + } + } + if (localTransaction?.status === 'submitted' && onUpdate) { + console.log('onUpdate', localTransaction); + onUpdate(localTransaction); + } + }, [localTransaction?.status, contTx?.status]); + + useEffect(() => { + if (!transaction) return; + if (transaction && !syncing.current) { + syncing.current = true; + transactionService.syncTransactionStatus(transaction, client); + } + }, [transaction.uuid]); + + const onSign = async (tx: ITransaction) => { + const signed = (await sign(tx)) as IUnsignedCommand; + const updated = { + ...tx, + ...signed, + status: isSignedCommand(signed) + ? steps.indexOf(tx.status) < steps.indexOf('signed') + ? 'signed' + : tx.status + : tx.status, + } as ITransaction; + await transactionRepository.updateTransaction(updated); + // setLocalTransaction(updated); + if (onUpdate) { + onUpdate(updated); + } + }; + + const onExpandedSign = + (tx: ITransaction) => async (sigs: ITransaction['sigs']) => { + const updated = { + ...tx, + sigs, + status: sigs.every((data) => data?.sig) + ? steps.indexOf(tx.status) < steps.indexOf('signed') + ? 'signed' + : tx.status + : tx.status, + } as ITransaction; + await transactionRepository.updateTransaction(updated); + // setLocalTransaction(updated); + if (onUpdate) { + onUpdate(updated); + } + }; + + const onSubmit = useCallback( + async (tx: ITransaction, skipPreflight = false) => { + const result = await transactionService.submitTransaction( + tx, + client, + undefined, + skipPreflight, + ); + if (onUpdate) onUpdate(result); + + return result; + }, + [client, onUpdate], + ); + + const onPreflight = useCallback( + async (tx: ITransaction) => { + const result = await transactionService.preflightTransaction( + tx, + client, + ); + if (onUpdate) onUpdate(result); + + return result; + }, + [client, onUpdate], + ); + + if (!localTransaction) return null; + const renderExpanded = (isDialog = false) => ( + + onSubmit(localTransaction, skipPreflight) + } + onPreflight={() => onPreflight(localTransaction)} + sendDisabled={sendDisabled} + showTitle={as === 'tile' || isDialog} + isDialog={isDialog} + /> + ); + if (as === 'tile' || as === 'minimized') + return ( + <> + {expandedModal && ( + { + if (!isOpen) { + setExpandedModal(false); + } + }} + > + {renderExpanded(true)} + + )} + {as === 'tile' && ( + { + onSign(localTransaction); + }} + onSubmit={() => onSubmit(localTransaction)} + onPreflight={() => onPreflight(localTransaction)} + onView={async () => { + setExpandedModal(true); + }} + /> + )} + {as === 'minimized' && ( + { + onSign(localTransaction); + }} + onSubmit={() => onSubmit(localTransaction)} + onView={async () => { + setExpandedModal(true); + }} + /> + )} + > + ); + + return renderExpanded(); + }, +); diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxList.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxList.tsx new file mode 100644 index 0000000000..f2f38e529b --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxList.tsx @@ -0,0 +1,228 @@ +import { + ITransaction, + transactionRepository, +} from '@/modules/transaction/transaction.repository'; + +import { Button, Stack, Text } from '@kadena/kode-ui'; + +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { ICommand, IUnsignedCommand } from '@kadena/client'; +import { MonoSignature } from '@kadena/kode-icons/system'; +import { isSignedCommand } from '@kadena/pactjs'; +import React, { useCallback, useEffect } from 'react'; + +import * as transactionService from '@/modules/transaction/transaction.service'; +import { normalizeSigs } from '@/utils/normalizeSigs'; +import { TxContainer } from './TxContainer'; +import { statusPassed, steps } from './TxPipeLine'; + +export const TxList = React.memo( + ({ + txIds, + sendDisabled, + onDone, + showExpanded, + onSign, + }: { + txIds: string[]; + showExpanded?: boolean; + sendDisabled?: boolean; + onDone?: () => void; + onSign?: (tx: ICommand) => void; + }) => { + const { sign, client, getPublicKeyData } = useWallet(); + const [transactions, setTransactions] = React.useState([]); + + useEffect(() => { + if (!txIds || txIds.length === 0) { + setTransactions([]); + return; + } + Promise.all(txIds.map(transactionRepository.getTransaction)).then( + setTransactions, + ); + }, [txIds]); + + const updateTx = useCallback( + (updatedTx: ITransaction) => + setTransactions((prev) => { + if (updatedTx.status === 'signed' && onSign) { + if ( + prev.find((tx) => tx.uuid === updatedTx.uuid)?.status !== 'signed' + ) { + onSign({ + cmd: updatedTx.cmd, + hash: updatedTx.hash, + sigs: updatedTx.sigs as ICommand['sigs'], + }); + } + } + return prev.map((prevTx) => + prevTx.uuid === updatedTx.uuid ? updatedTx : prevTx, + ); + }), + [], + ); + + const signAll = async () => { + const txs = await Promise.all( + txIds.map(transactionRepository.getTransaction), + ); + const signed = (await sign(txs)) as (IUnsignedCommand | ICommand)[]; + + const updatedTxs = txs.map((tx) => { + const signedTx = signed.find(({ hash }) => hash === tx.hash); + if (!signedTx) return tx; + const updatedTx = { + ...tx, + ...signedTx, + status: isSignedCommand(signedTx) + ? steps.indexOf(tx.status) < steps.indexOf('signed') + ? 'signed' + : tx.status + : tx.status, + } as ITransaction; + if (updatedTx.status === 'signed' && onSign) { + onSign({ + cmd: updatedTx.cmd, + hash: updatedTx.hash, + sigs: updatedTx.sigs as ICommand['sigs'], + }); + } + return updatedTx; + }); + await updatedTxs.map(transactionRepository.updateTransaction); + setTransactions(updatedTxs); + }; + + const onSendAll = async () => { + const onSubmit = async (tx: ITransaction) => { + return transactionService.submitTransaction(tx, client, updateTx); + }; + + const txs = await Promise.all( + txIds.map(transactionRepository.getTransaction), + ); + const result = await Promise.all(txs.map(onSubmit)); + if (onDone) { + onDone(); + } + console.log(result); + }; + + const onPreflightAll = async () => { + const onPreflight = async (tx: ITransaction) => { + return transactionService.preflightTransaction(tx, client); + }; + + const txs = await Promise.all( + txIds.map(transactionRepository.getTransaction), + ); + const result = await Promise.all(txs.map(onPreflight)); + result.forEach(updateTx); + console.log(result); + }; + + const signedByYou = (tx: ITransaction) => { + const signers = normalizeSigs(tx); + return !signers.find( + (sigData) => !sigData?.sig && getPublicKeyData(sigData?.pubKey), + ); + }; + + return ( + + + {transactions.length === 0 && No transactions} + {!showExpanded && + transactions.map((tx) => ( + + ))} + {showExpanded && + transactions.map((tx) => ( + + + + ))} + + {!showExpanded && !transactions.every((tx) => signedByYou(tx)) && ( + + You can sign all transactions at once. + + + + + Sign All Transactions + + + + + )} + {!showExpanded && + transactions.every((tx) => signedByYou(tx)) && + !transactions.every((tx) => statusPassed(tx.status, 'signed')) && ( + + + There is no action at the moment; share the transactions with + other signers to sign + + + )} + {!showExpanded && + !sendDisabled && + transactions.every((tx) => statusPassed(tx.status, 'signed')) && + transactions.find((tx) => tx.status === 'signed') && ( + + + All transactions are signed. Now you can call preflight + + + onPreflightAll()}> + Preflight transactions + + + + )} + {!showExpanded && + !sendDisabled && + transactions.every((tx) => statusPassed(tx.status, 'preflight')) && + transactions.find((tx) => tx.status === 'preflight') && ( + + + All transactions are signed. Now you can send them to the + blockchain + + + onSendAll()}> + Send transactions + + + + )} + + ); + }, + (prev, next) => { + if (prev.sendDisabled !== next.sendDisabled) return false; + if (prev.showExpanded !== next.showExpanded) return false; + if (prev.txIds.length !== next.txIds.length) return false; + return prev.txIds.every((txId, index) => txId === next.txIds[index]); + }, +); diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxMinimized.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxMinimized.tsx new file mode 100644 index 0000000000..cb3871b235 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxMinimized.tsx @@ -0,0 +1,58 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; + +import { MonoOpenInFull } from '@kadena/kode-icons/system'; +import { Button, Stack } from '@kadena/kode-ui'; + +import { TxPipeLine } from './TxPipeLine'; +import { txMinimizedClass } from './style.css'; + +export const TxMinimized = ({ + tx, + contTx, + onSign, + onSubmit, + onView, + sendDisabled, + interactive = false, +}: { + tx: ITransaction; + contTx?: ITransaction; + onSign: () => void; + onSubmit: () => Promise; + onView: () => void; + sendDisabled?: boolean; + interactive?: boolean; +}) => { + return ( + + + + {interactive && tx.status === 'initiated' && ( + onSign()}> + Sign + + )} + {interactive && tx.status === 'signed' && ( + onSubmit()} + > + Send + + )} + + + + + + + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxPipeLine.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxPipeLine.tsx new file mode 100644 index 0000000000..8748b2631e --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxPipeLine.tsx @@ -0,0 +1,426 @@ +import { + ITransaction, + TransactionStatus, +} from '@/modules/transaction/transaction.repository'; +import { syncTransactionStatus } from '@/modules/transaction/transaction.service'; +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { shorten } from '@/utils/helpers'; +import { normalizeSigs } from '@/utils/normalizeSigs'; +import { base64UrlEncodeArr } from '@kadena/cryptography-utils'; +import { + MonoCheck, + MonoClose, + MonoCloudSync, + MonoLoading, + MonoPauseCircle, + MonoRefresh, + MonoShare, + MonoSignature, + MonoViewInAr, +} from '@kadena/kode-icons/system'; +import { Button, Stack, Text } from '@kadena/kode-ui'; +import { useMemo, useState } from 'react'; +import { failureClass, pendingClass, successClass } from './style.css'; + +export const steps: TransactionStatus[] = [ + 'initiated', + 'signed', + 'preflight', + 'submitted', + 'failure', + 'success', + 'persisted', +]; + +export const statusPassed = ( + txStatus: ITransaction['status'], + status: ITransaction['status'], +) => steps.indexOf(txStatus) >= steps.indexOf(status); + +export const getStatusClass = (status: ITransaction['status']) => { + if (statusPassed(status, 'success')) return successClass; + if (status === 'failure') return failureClass; + if (status === 'initiated') return ''; + return pendingClass; +}; + +export function TxPipeLine({ + tx, + contTx, + variant, + signAll, + onSubmit, + sendDisabled, + onPreflight, +}: { + tx: ITransaction; + contTx?: ITransaction; + variant: 'tile' | 'expanded' | 'minimized'; + signAll?: () => void; + onSubmit?: (skipPreflight?: boolean) => void; + onPreflight?: () => void; + sendDisabled?: boolean; +}) { + const showAfterCont = !contTx || variant === 'expanded'; + return ( + + + + ); +} + +function TxStatusList({ + variant, + showAfterCont, + tx, + signAll, + onSubmit = () => {}, + sendDisabled, + contTx, + onPreflight = () => {}, +}: { + variant: 'tile' | 'expanded' | 'minimized'; + showAfterCont: boolean; + tx: ITransaction; + signAll?: () => void; + onSubmit?: (skipPreflight?: boolean) => void; + onPreflight?: () => void; + sendDisabled?: boolean; + contTx?: ITransaction | null; +}) { + const { getPublicKeyData, client } = useWallet(); + const signers = useMemo(() => normalizeSigs(tx), [tx]); + const textSize = variant === 'tile' ? 'smallest' : 'base'; + const signedByYou = !signers.find( + (sigData) => !sigData?.sig && getPublicKeyData(sigData?.pubKey), + ); + const [copied, setCopied] = useState(false); + const statusList = [ + variant !== 'minimized' && ( + + + {tx.continuation?.autoContinue ? 'exec' : 'hash'}:{' '} + {shorten(tx.hash, 6)} + + + ), + showAfterCont && + variant !== 'tile' && + !statusPassed(tx.status, 'signed') && ( + + + + + {signedByYou ? 'add external signatures' : 'Waiting for sign'} + + + + {variant === 'expanded' && signedByYou && ( + + } + isCompact + variant="outlined" + onClick={() => { + syncTransactionStatus(tx, client); + }} + > + query chain + + } + isCompact + onClick={() => { + const encodedTx = base64UrlEncodeArr( + new TextEncoder().encode( + JSON.stringify({ + hash: tx.hash, + cmd: tx.cmd, + sigs: tx.sigs, + }), + ), + ); + const baseUrl = `${window.location.protocol}//${window.location.host}`; + navigator.clipboard.writeText( + `${baseUrl}/sig-builder#${encodedTx}`, + ); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + > + {copied ? 'copied' : 'Share'} + + + )} + {variant === 'expanded' && !signedByYou && ( + + signAll!()} + startVisual={} + > + Sign all possible signers + + + )} + + ), + showAfterCont && statusPassed(tx.status, 'signed') && ( + + + + + Signed + + + + ), + showAfterCont && + variant !== 'tile' && + statusPassed(tx.status, 'signed') && + !statusPassed(tx.status, 'preflight') && ( + + + + + {sendDisabled ? 'Transaction is pending' : 'Ready to preflight'} + + + {variant === 'expanded' && ( + onPreflight()} + isDisabled={sendDisabled} + startVisual={} + > + Preflight transaction + + )} + + ), + showAfterCont && statusPassed(tx.status, 'preflight') && ( + + + + {tx.preflight?.result.status === 'success' ? ( + + ) : ( + + )} + preflight + + + {variant === 'expanded' && + tx.status === 'preflight' && + tx.preflight?.result.status === 'failure' && ( + + onPreflight()}> + + + onSubmit(true)} + > + Skip + + + )} + + ), + showAfterCont && + variant !== 'tile' && + tx.status === 'preflight' && + tx.preflight?.result.status === 'success' && ( + + + + + Ready to send + + + {variant === 'expanded' && ( + onSubmit()} + isDisabled={sendDisabled} + startVisual={} + > + Send transaction + + )} + + ), + showAfterCont && statusPassed(tx.status, 'submitted') && ( + + + + {tx.request ? : } + Send + + + {variant === 'expanded' && !tx.request && ( + + onSubmit(true)}> + + + + )} + + ), + showAfterCont && + statusPassed(tx.status, 'submitted') && + (!('result' in tx) || !tx.result) && ( + + + + + Mining... + + + + ), + statusPassed(tx.status, 'success') && ( + + + + + Mined{' '} + {tx.continuation?.autoContinue && contTx + ? `in chain ${tx.purpose!.data.source as string}` + : ''} + + + + ), + tx.status === 'failure' && ( + + + + + Failed + + + + ), + statusPassed(tx.status, 'success') && [ + tx.continuation?.autoContinue && !tx.continuation.proof && ( + + + + + Fetching proof + + + + ), + showAfterCont && + tx.continuation?.autoContinue && + tx.continuation.proof && ( + + + + + proof fetched + + + + ), + contTx && [ + variant !== 'minimized' && ( + + cont: {shorten(contTx.hash, 6)} + + ), + statusPassed(contTx.status, 'preflight') && ( + + + + {contTx.preflight?.result.status === 'success' ? ( + + ) : ( + + )} + preflight + + + + ), + statusPassed(contTx.status, 'submitted') && ( + + + + + Send + + + + ), + statusPassed(contTx.status, 'submitted') && + (!('result' in contTx) || !contTx.result) && ( + + + + + Mining... + + + + ), + statusPassed(contTx.status, 'success') && ( + + + + + Mined + + + + ), + contTx.status === 'failure' && ( + + + + + Failed + + + + ), + ], + ], + ] + .flat(Infinity) + .filter(Boolean); + + if (variant === 'minimized') return statusList.pop() as JSX.Element; + return <>{statusList}>; +} diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTile.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTile.tsx new file mode 100644 index 0000000000..a0c66bf839 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTile.tsx @@ -0,0 +1,195 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; + +import { + MonoCheck, + MonoOpenInFull, + MonoShare, + MonoSignature, + MonoViewInAr, +} from '@kadena/kode-icons/system'; +import { Button, Stack, Text } from '@kadena/kode-ui'; + +import { IPactCommand } from '@kadena/client'; + +import { useWallet } from '@/modules/wallet/wallet.hook'; +import { normalizeSigs } from '@/utils/normalizeSigs'; +import { shortenPactCode } from '@/utils/parsedCodeToPact'; +import { base64UrlEncodeArr } from '@kadena/cryptography-utils'; +import { useMemo, useState } from 'react'; +import { Value } from './helpers'; +import { + codeClass, + successClass, + txTileClass, + txTileContentClass, +} from './style.css'; +import { TxPipeLine } from './TxPipeLine'; + +export const TxTile = ({ + tx, + contTx, + onSign, + onSubmit, + onPreflight, + onView, + sendDisabled, +}: { + tx: ITransaction; + contTx?: ITransaction; + onSign: () => void; + onSubmit: () => Promise; + onPreflight: () => Promise; + onView: () => void; + sendDisabled?: boolean; +}) => { + const command: IPactCommand = JSON.parse(tx.cmd); + const { getPublicKeyData } = useWallet(); + const signers = useMemo(() => normalizeSigs(tx), [tx]); + const signedByYou = !signers.find( + (sigData) => !sigData?.sig && getPublicKeyData(sigData?.pubKey), + ); + const [shareClicked, setCopyClick] = useState(false); + + function shareTx() { + const encodedTx = base64UrlEncodeArr( + new TextEncoder().encode( + JSON.stringify({ + hash: tx.hash, + cmd: tx.cmd, + sigs: tx.sigs, + }), + ), + ); + const baseUrl = `${window.location.protocol}//${window.location.host}`; + navigator.clipboard.writeText(`${baseUrl}/sig-builder#${encodedTx}`); + setCopyClick(true); + setTimeout(() => setCopyClick(false), 5000); + } + + return ( + + + + {tx.status === 'initiated' && !signedByYou && ( + <> + {'exec' in command.payload && ( + <> + + + + {shortenPactCode(command.payload.exec.code)} + + + + > + )} + {'cont' in command.payload && ( + <> + + Continuation + + {command.payload.cont.pactId}- step( + {command.payload.cont.step}) + + + > + )} + > + )} + {tx.status === 'initiated' && signedByYou && ( + + + + + + Signed by you + + + + + You have signed this transaction, share the tx with others to + sign; + + + )} + {tx.status === 'signed' && ( + <> + + + The transaction is Signed; you can now call preflight + + + > + )} + + + + {tx.status === 'initiated' && !signedByYou && ( + + + + Sign + + + )} + {tx.status === 'initiated' && signedByYou && ( + + + + {shareClicked ? 'Copied!' : 'Share'} + + + )} + {tx.status === 'signed' && !sendDisabled && ( + + + + Preflight + + + )} + {tx.status === 'preflight' && + tx.preflight.result.status === 'success' && + !sendDisabled && ( + + + + Send + + + )} + + + + + Expand + + + + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTileGeneric.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTileGeneric.tsx new file mode 100644 index 0000000000..f7bad7426e --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/TxTileGeneric.tsx @@ -0,0 +1,127 @@ +import { ITransaction } from '@/modules/transaction/transaction.repository'; + +import { MonoCheck } from '@kadena/kode-icons/system'; +import { Button, Stack, Text } from '@kadena/kode-ui'; + +import { IPactCommand } from '@kadena/client'; + +import { shortenPactCode } from '@/utils/parsedCodeToPact'; +import { Value } from './helpers'; +import { + codeClass, + failureClass, + successClass, + txTileClass, + txTileContentClass, +} from './style.css'; +import { TxPipeLine } from './TxPipeLine'; + +export const TxTileGeneric = ({ + tx, + contTx, + buttons, + subtexts, +}: { + tx: ITransaction; + contTx?: ITransaction; + subtexts?: React.FC[]; + buttons: { + label: string; + onClick: () => void; + Icon?: React.FC<{ className?: string }>; + position: 'flex-start' | 'flex-end'; + }[]; +}) => { + const command: IPactCommand = JSON.parse(tx.cmd); + + return ( + + + + { + <> + {'exec' in command.payload && ( + <> + + + {subtexts && subtexts.map((SubText) => )} + + + + + {shortenPactCode(command.payload.exec.code)} + + + + > + )} + {'cont' in command.payload && ( + <> + + Continuation + + {command.payload.cont.pactId}- step( + {command.payload.cont.step}) + + + > + )} + > + } + + + + + {buttons + .filter((b) => b.position === 'flex-start') + .map(({ label, onClick, Icon }) => ( + <> + + + {Icon && } + {label} + + {' '} + > + ))} + + + + {buttons + .filter((b) => b.position === 'flex-end') + .map(({ label, onClick, Icon }) => ( + <> + + + {Icon && } + {label} + + {' '} + > + ))} + + + + ); +}; diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/helpers.tsx b/packages/apps/dev-wallet/src/pages/transaction-group/components/helpers.tsx new file mode 100644 index 0000000000..3b717e48c8 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/helpers.tsx @@ -0,0 +1,23 @@ +import { Text } from '@kadena/kode-ui'; +import { FC, PropsWithChildren } from 'react'; +import { labelBoldClass, labelClass } from './style.css.ts'; + +export const Label: FC< + PropsWithChildren<{ + bold?: boolean; + size?: 'small' | 'smallest' | 'base' | 'inherit'; + }> +> = ({ children, bold, size }) => ( + + {children} + +); + +export const Value: FC> = ({ + children, + className, +}) => ( + + {children} + +); diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/components/style.css.ts b/packages/apps/dev-wallet/src/pages/transaction-group/components/style.css.ts new file mode 100644 index 0000000000..67c0eca24b --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/components/style.css.ts @@ -0,0 +1,139 @@ +import { tokens, vars } from '@kadena/kode-ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const footerClass = style({ + paddingBlockStart: '10px', +}); + +export const breakAllClass = style({ + wordBreak: 'break-all', +}); + +export const cardClass = style({ + padding: '10px', + border: `1px solid ${vars.colors.$layoutSurfaceCard}`, + borderRadius: '5px', +}); + +export const codeClass = style({ + padding: '10px', + borderRadius: '3px', + backgroundColor: tokens.kda.foundation.color.neutral.n10, + flex: '1', + flexBasis: 0, +}); + +export const labelClass = style({ + maxWidth: '200px', + flex: '1', +}); + +export const labelBoldClass = style({ + fontWeight: '700', + color: tokens.kda.foundation.color.text.base.default, +}); + +export const containerClass = style({ + padding: '30px', + border: `1px solid ${vars.colors.$layoutSurfaceCard}`, + borderRadius: '5px', + backgroundColor: tokens.kda.foundation.color.neutral.n1, +}); + +export const signedClass = style({ + background: vars.colors.$positiveContrast, + padding: '2px 5px', + borderRadius: '3px', + fontWeight: 'bold', +}); + +export const readyToSignClass = style({ + background: vars.colors.$primaryLowContrast, + padding: '2px 5px', + borderRadius: '3px', + fontWeight: 'bold', +}); + +export const tagClass = style({ + background: vars.colors.$layoutSurfaceCard, + padding: '2px 5px', + borderRadius: '3px', + fontWeight: 'bold', +}); + +export const pendingClass = style({ + color: vars.colors.$warningHighContrast, +}); + +export const successClass = style({ + color: vars.colors.$positiveSurface, +}); + +export const failureClass = style({ + color: vars.colors.$negativeAccent, +}); + +export const pendingText = style({ + opacity: 0.5, +}); + +export const textEllipsis = style({ + overflow: 'hidden', + minHeight: '2.2em', + // overflowY: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', +}); + +export const tabTextClass = style({ + width: '50px', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', +}); + +export const tabClass = style({ + borderBottom: `0px solid ${vars.colors.$infoContrast}`, + selectors: { + '&.selected': { + borderWidth: '2px', + }, + }, +}); + +export const txTileClass = style({ + width: '250px', + height: '260px', + padding: '10px', + border: `1px solid ${vars.colors.$borderDefault}`, + borderRadius: '5px', + overflow: 'hidden', +}); + +export const txTileContentClass = style({ + flexBasis: 0, +}); + +export const txMinimizedClass = style({ + padding: '3px', + paddingLeft: '5px', + border: `1px solid ${tokens.kda.foundation.color.border.base.default}`, +}); + +export const txExpandedWrapper = style({ + flexDirection: 'column', + '@media': { + 'screen and (min-width: 1024px)': { + flexDirection: 'row', + }, + }, +}); + +export const txDetailsClass = style({ + maxWidth: '100%', + '@media': { + 'screen and (min-width: 1024px)': { + maxWidth: 'calc(100% - 285px)', + }, + }, +}); diff --git a/packages/apps/dev-wallet/src/pages/transaction-group/style.css.ts b/packages/apps/dev-wallet/src/pages/transaction-group/style.css.ts new file mode 100644 index 0000000000..873b9b8456 --- /dev/null +++ b/packages/apps/dev-wallet/src/pages/transaction-group/style.css.ts @@ -0,0 +1,18 @@ +import { vars } from '@kadena/kode-ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const tabStyle = style({ + padding: 0, + paddingBottom: '2px', + backgroundColor: 'transparent', + border: 'none', + color: vars.colors.$borderContrast, + borderBottom: 'solid 2px transparent', + cursor: 'pointer', + selectors: { + '&.selected': { + borderBottomColor: vars.colors.$foreground, + color: vars.colors.$foreground, + }, + }, +}); diff --git a/packages/apps/dev-wallet/src/pages/unlock-profile/unlock-profile.tsx b/packages/apps/dev-wallet/src/pages/unlock-profile/unlock-profile.tsx index 1bbc9d5ba7..a344fa1947 100644 --- a/packages/apps/dev-wallet/src/pages/unlock-profile/unlock-profile.tsx +++ b/packages/apps/dev-wallet/src/pages/unlock-profile/unlock-profile.tsx @@ -100,6 +100,7 @@ export function UnlockProfile({ origin }: { origin: string }) { })} isInvalid={!isValid && !!errors.password} errorMessage={errors.password?.message} + autoFocus /> diff --git a/packages/apps/dev-wallet/src/utils/select-file.ts b/packages/apps/dev-wallet/src/utils/select-file.ts index ad6c3e44fa..748f814443 100644 --- a/packages/apps/dev-wallet/src/utils/select-file.ts +++ b/packages/apps/dev-wallet/src/utils/select-file.ts @@ -1,7 +1,10 @@ // create a file input element -function createFileInput(types?: string[]) { +function createFileInput(multiple: boolean, types: string[] = []) { const input: HTMLInputElement = document.createElement('input'); input.setAttribute('type', 'file'); + if (multiple) { + input.setAttribute('multiple', 'multiple'); + } if (types && types.length) { input.setAttribute('accept', types.join(', ')); } @@ -13,7 +16,7 @@ export function browse( multiple = false, types?: string[], ): Promise { - const input: HTMLInputElement = createFileInput(types); + const input: HTMLInputElement = createFileInput(true, types); input.click(); // fire click event return new Promise((resolve) => { input.onchange = function (event) { diff --git a/packages/apps/dev-wallet/src/utils/transaction-scheme.ts b/packages/apps/dev-wallet/src/utils/transaction-scheme.ts index 95db8bb7eb..c1911b4b1a 100644 --- a/packages/apps/dev-wallet/src/utils/transaction-scheme.ts +++ b/packages/apps/dev-wallet/src/utils/transaction-scheme.ts @@ -6,7 +6,8 @@ export type RequestScheme = | 'quickSignRequest' | 'signingRequest' | 'PactCommand' - | 'base64'; + | 'base64' + | 'Array'; export function determineSchema( input: string,
+ {shortenPactCode(command.payload.exec.code)} +
+ {JSON.stringify(command.payload.exec.data, null, 2)} +
+ {JSON.stringify(command.payload.cont.data, null, 2)} +
+ {data && typeof data === 'object' + ? JSON.stringify(data, null, 2) + : shortening + ? shorten(data, shortening) + : data} +
+ {JSON.stringify(transaction.preflight?.result.data, null, 2)} +
{JSON.stringify(transaction.result.result.data, null, 2)}