diff --git a/src/App.tsx b/src/App.tsx index ecc8a7b..fdd735e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -155,6 +155,7 @@ import { BuyQortInformation } from "./components/BuyQortInformation"; import { InstallPWA } from "./components/InstallPWA"; import { QortPayment } from "./components/QortPayment"; import { PdfViewer } from "./common/PdfViewer"; +import { DownloadWallet } from "./components/Auth/DownloadWallet"; type extStates = @@ -2584,87 +2585,14 @@ function App() { )} {extState === "download-wallet" && ( <> - - - - - -
- -
- - - - Download Account - - - - {!walletToBeDownloaded && ( - <> - - Confirm Wallet Password - - - - setWalletToBeDownloadedPassword(e.target.value) - } - /> - - - Confirm password - - {walletToBeDownloadedError} - - )} - - {walletToBeDownloaded && ( - <> - { - await saveFileToDiskFunc() -await showInfo({ - message: isNative ? `Your account file was saved to internal storage, in the document folder. Keep that file secure.` : `Your account file was downloaded by your browser. Keep that file secure.` , - }) - }}> - Download account - - - )} + )} {extState === "create-wallet" && ( diff --git a/src/background.ts b/src/background.ts index 8fbada2..6c1f5b6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -928,6 +928,59 @@ export async function getBalanceInfo() { const data = await response.json(); return data; } + +export async function getAssetBalanceInfo(assetId: number) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const validApi = await getBaseApi(); + const response = await fetch(validApi + `/assets/balances?address=${address}&assetid=${assetId}&ordering=ASSET_BALANCE_ACCOUNT&limit=1`); + + if (!response?.ok) throw new Error("Cannot fetch asset balance"); + const data = await response.json(); + return +data?.[0]?.balance +} + +export async function getAssetInfo(assetId: number) { + const validApi = await getBaseApi(); + const response = await fetch(validApi + `/assets/info?assetId=${assetId}`); + + if (!response?.ok) throw new Error("Cannot fetch asset info"); + const data = await response.json(); + return data +} + +export async function transferAsset({ + amount, + recipient, + assetId, +}) { + const lastReference = await getLastRef(); + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + const feeres = await getFee("TRANSFER_ASSET"); + + const tx = await createTransaction(12, keyPair, { + fee: feeres.fee, + recipient: recipient, + amount: amount, + assetId: assetId, + lastReference: lastReference, + }); + + + const signedBytes = Base58.encode(tx.signedBytes); + + const res = await processTransactionVersion2(signedBytes); + if (!res?.signature) + throw new Error(res?.message || "Transaction was not able to be processed"); + return res; +} export async function getLTCBalance() { const wallet = await getSaveWallet(); let _url = `${buyTradeNodeBaseUrl}/crosschain/ltc/walletbalance`; diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx index 5d975b5..678880f 100644 --- a/src/components/Apps/useQortalMessageListener.tsx +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -259,7 +259,9 @@ export function openIndexedDB() { 'UPDATE_GROUP', 'SELL_NAME', 'CANCEL_SELL_NAME', - 'BUY_NAME' + 'BUY_NAME', 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'TRANSFER_ASSET', + 'SIGN_FOREIGN_FEES', ] @@ -275,7 +277,9 @@ const UIQortalRequests = [ 'CREATE_TRADE_SELL_ORDER', 'CANCEL_TRADE_SELL_ORDER', 'IS_USING_PUBLIC_NODE', 'SIGN_TRANSACTION', 'ADMIN_ACTION', 'OPEN_NEW_TAB', 'CREATE_AND_COPY_EMBED_LINK', 'DECRYPT_QORTAL_GROUP_DATA', 'DECRYPT_DATA_WITH_SHARING_KEY', 'DELETE_HOSTED_DATA', 'GET_HOSTED_DATA', 'SHOW_ACTIONS', 'REGISTER_NAME', 'UPDATE_NAME', 'LEAVE_GROUP', 'INVITE_TO_GROUP', 'KICK_FROM_GROUP', 'BAN_FROM_GROUP', 'CANCEL_GROUP_BAN', 'ADD_GROUP_ADMIN', 'REMOVE_GROUP_ADMIN','DECRYPT_AESGCM', 'CANCEL_GROUP_INVITE', 'CREATE_GROUP', 'GET_USER_WALLET_TRANSACTIONS', 'GET_NODE_INFO', 'GET_NODE_STATUS', 'GET_ARRR_SYNC_STATUS', 'SHOW_PDF_READER', 'UPDATE_GROUP', 'SELL_NAME', 'CANCEL_SELL_NAME', - 'BUY_NAME' + 'BUY_NAME', 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'TRANSFER_ASSET', + 'SIGN_FOREIGN_FEES', ]; diff --git a/src/components/Auth/DownloadWallet.tsx b/src/components/Auth/DownloadWallet.tsx new file mode 100644 index 0000000..b709b9f --- /dev/null +++ b/src/components/Auth/DownloadWallet.tsx @@ -0,0 +1,248 @@ +import { + Box, + Checkbox, + FormControlLabel, + Typography, + useTheme, +} from '@mui/material'; +import { Spacer } from '../../common/Spacer'; +import { PasswordField } from '../PasswordField/PasswordField'; +import { ErrorText } from '../ErrorText/ErrorText'; +import Logo1Dark from '../../assets/svgs/Logo1Dark.svg'; +import { saveFileToDisk } from '../../utils/generateWallet/generateWallet'; +import { useState } from 'react'; +import { decryptStoredWallet } from '../../utils/decryptWallet'; +import PhraseWallet from '../../utils/generateWallet/phrase-wallet'; +import { crypto, walletVersion } from '../../constants/decryptWallet'; +import Return from "../../assets/svgs/Return.svg"; +import { CustomButton, CustomLabel, TextP } from '../../App-styles'; + +export const DownloadWallet = ({ + returnToMain, + setIsLoading, + showInfo, + rawWallet, + setWalletToBeDownloaded, + walletToBeDownloaded, +}) => { + const [walletToBeDownloadedPassword, setWalletToBeDownloadedPassword] = + useState(''); + const [newPassword, setNewPassword] = useState(''); + const [keepCurrentPassword, setKeepCurrentPassword] = useState(true); + const theme = useTheme(); + const [walletToBeDownloadedError, setWalletToBeDownloadedError] = + useState(''); + + + const saveFileToDiskFunc = async () => { + try { + await saveFileToDisk( + walletToBeDownloaded.wallet, + walletToBeDownloaded.qortAddress + ); + } catch (error: any) { + setWalletToBeDownloadedError(error?.message); + } + }; + + const saveWalletFunc = async (password: string, newPassword) => { + let wallet = structuredClone(rawWallet); + + const res = await decryptStoredWallet(password, wallet); + const wallet2 = new PhraseWallet(res, wallet?.version || walletVersion); + const passwordToUse = newPassword || password; + wallet = await wallet2.generateSaveWalletData( + passwordToUse, + crypto.kdfThreads, + () => {} + ); + + setWalletToBeDownloaded({ + wallet, + qortAddress: rawWallet.address0, + }); + return { + wallet, + qortAddress: rawWallet.address0, + }; + }; + + const confirmPasswordToDownload = async () => { + try { + setWalletToBeDownloadedError(''); + if (!keepCurrentPassword && !newPassword) { + setWalletToBeDownloadedError( + 'Please enter a new password' + ); + return; + } + if (!walletToBeDownloadedPassword) { + setWalletToBeDownloadedError( + 'Please enter your password' + ); + return; + } + setIsLoading(true); + await new Promise((res) => { + setTimeout(() => { + res(); + }, 250); + }); + const newPasswordForWallet = !keepCurrentPassword ? newPassword : null; + const res = await saveWalletFunc( + walletToBeDownloadedPassword, + newPasswordForWallet + ); + } catch (error: any) { + setWalletToBeDownloadedError(error?.message); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + + + + + + + + +
+ +
+ + + + + + Download account + + + + + + {!walletToBeDownloaded && ( + <> + + Confirm password + + + + + setWalletToBeDownloadedPassword(e.target.value)} + /> + + + + setKeepCurrentPassword(e.target.checked)} + checked={keepCurrentPassword} + edge="start" + tabIndex={-1} + disableRipple + sx={{ + '&.Mui-checked': { + color: theme.palette.text.secondary, + }, + '& .MuiSvgIcon-root': { + color: theme.palette.text.secondary, + }, + }} + /> + } + label={ + + + Keep current password + + + } + /> + + {!keepCurrentPassword && ( + <> + + New password + + + + setNewPassword(e.target.value)} + /> + + + )} + + + Confirm wallet password + + + {walletToBeDownloadedError} + + )} + + {walletToBeDownloaded && ( + <> + { + await saveFileToDiskFunc(); + await showInfo({ + message: 'Keep your account file secure', + }); + }} + > + Download account + + + )} + + ); +}; diff --git a/src/components/UserLookup.tsx/UserLookup.tsx b/src/components/UserLookup.tsx/UserLookup.tsx index 566f672..3499e90 100644 --- a/src/components/UserLookup.tsx/UserLookup.tsx +++ b/src/components/UserLookup.tsx/UserLookup.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { DrawerUserLookup } from "../Drawer/DrawerUserLookup"; import { Avatar, @@ -16,6 +16,7 @@ import { Typography, Table, CircularProgress, + Autocomplete, } from "@mui/material"; import { getAddressInfo, getNameOrAddress } from "../../background"; import { getBaseApiReact } from "../../App"; @@ -26,6 +27,7 @@ import { formatTimestamp } from "../../utils/time"; import CloseFullscreenIcon from '@mui/icons-material/CloseFullscreen'; import SearchIcon from '@mui/icons-material/Search'; import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from "../../utils/events"; +import { useNameSearch } from "../../hooks/useNameSearch"; function formatAddress(str) { if (str.length <= 12) return str; @@ -38,6 +40,9 @@ function formatAddress(str) { export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { const [nameOrAddress, setNameOrAddress] = useState(""); + const [inputValue, setInputValue] = useState(''); + const { results, isLoading } = useNameSearch(inputValue); + const options = useMemo(() => results?.map((item) => item.name), [results]); const [errorMessage, setErrorMessage] = useState(""); const [addressInfo, setAddressInfo] = useState(null); const [isLoadingUser, setIsLoadingUser] = useState(false); @@ -106,6 +111,7 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { setIsOpenDrawerLookup(false) setNameOrAddress('') setErrorMessage('') + setInputValue(''); setPayments([]) setIsLoadingUser(false) setIsLoadingPayments(false) @@ -134,27 +140,66 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { flexShrink: 0, }} > - setNameOrAddress(e.target.value)} - size="small" - placeholder="Address or Name" - autoComplete="off" - onKeyDown={(e) => { - if (e.key === "Enter" && nameOrAddress) { - lookupFunc(); + onChange={(event: any, newValue: string | null) => { + if (!newValue) { + setNameOrAddress(''); + return; } + setNameOrAddress(newValue); + lookupFunc(newValue); }} + inputValue={inputValue} + onInputChange={(event, newInputValue) => { + setInputValue(newInputValue); + }} + id="controllable-states-demo" + loading={isLoading} + options={options} + sx={{ width: 300 }} + size="small" + renderInput={(params) => ( + { + if (e.key === 'Enter' && nameOrAddress) { + lookupFunc(inputValue); + } + }} + + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { + borderColor: 'white', + }, + '&:hover fieldset': { + borderColor: 'white', + }, + '&.Mui-focused fieldset': { + borderColor: 'white', + }, + '& input': { + color: 'white', + }, + }, + '& .MuiInputLabel-root': { + color: 'white', + }, + '& .MuiInputLabel-root.Mui-focused': { + color: 'white', + }, + '& .MuiAutocomplete-endAdornment svg': { + color: 'white', + }, + }} + /> + )} /> - { - lookupFunc(); - }} > - - + { + const [nameList, setNameList] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const checkIfNameExisits = useCallback( + async (name: string, listLimit: number) => { + try { + if (!name) { + setNameList([]); + return; + } + + const res = await fetch( + `${getBaseApiReact()}/names/search?query=${name}&prefix=true&limit=${listLimit}` + ); + const data = await res.json(); + setNameList( + data?.map((item: any) => { + return { + name: item.name, + address: item.owner, + }; + }) + ); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }, + [] + ); + // Debounce logic + useEffect(() => { + setIsLoading(true); + const handler = setTimeout(() => { + checkIfNameExisits(value, limit); + }, 500); + + // Cleanup timeout if searchValue changes before the timeout completes + return () => { + clearTimeout(handler); + }; + }, [value, limit, checkIfNameExisits]); + return { + isLoading, + results: nameList, + }; +}; diff --git a/src/qortalRequests.ts b/src/qortalRequests.ts index d8ef5e5..8bf5a52 100644 --- a/src/qortalRequests.ts +++ b/src/qortalRequests.ts @@ -1,6 +1,6 @@ import { gateways, getApiKeyFromStorage } from "./background"; import { listOfAllQortalRequests } from "./components/Apps/useQortalMessageListener"; -import { addForeignServer, addGroupAdminRequest, addListItems, adminAction, banFromGroupRequest, buyNameRequest, cancelGroupBanRequest, cancelGroupInviteRequest, cancelSellNameRequest, cancelSellOrder, createAndCopyEmbedLink, createBuyOrder, createGroupRequest, createPoll, decryptAESGCMRequest, decryptData, decryptDataWithSharingKey, decryptQortalGroupData, deleteHostedData, deleteListItems, deployAt, encryptData, encryptDataWithSharingKey, encryptQortalGroupData, getArrrSyncStatus, getCrossChainServerInfo, getDaySummary, getForeignFee, getHostedData, getListItems, getNodeInfo, getNodeStatus, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getUserWalletTransactions, getWalletBalance, inviteToGroupRequest, joinGroup, kickFromGroupRequest, leaveGroupRequest, openNewTab, publishMultipleQDNResources, publishQDNResource, registerNameRequest, removeForeignServer, removeGroupAdminRequest, saveFile, sellNameRequest, sendChatMessage, sendCoin, setCurrentForeignServer, signTransaction, updateForeignFee, updateGroupRequest, updateNameRequest, voteOnPoll } from "./qortalRequests/get"; +import { addForeignServer, addGroupAdminRequest, addListItems, adminAction, banFromGroupRequest, buyNameRequest, cancelGroupBanRequest, cancelGroupInviteRequest, cancelSellNameRequest, cancelSellOrder, createAndCopyEmbedLink, createBuyOrder, createGroupRequest, createPoll, decryptAESGCMRequest, decryptData, decryptDataWithSharingKey, decryptQortalGroupData, deleteHostedData, deleteListItems, deployAt, encryptData, encryptDataWithSharingKey, encryptQortalGroupData, getArrrSyncStatus, getCrossChainServerInfo, getDaySummary, getForeignFee, getHostedData, getListItems, getNodeInfo, getNodeStatus, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getUserWalletTransactions, getWalletBalance, inviteToGroupRequest, joinGroup, kickFromGroupRequest, leaveGroupRequest, multiPaymentWithPrivateData, openNewTab, publishMultipleQDNResources, publishQDNResource, registerNameRequest, removeForeignServer, removeGroupAdminRequest, saveFile, sellNameRequest, sendChatMessage, sendCoin, setCurrentForeignServer, signForeignFees, signTransaction, transferAssetRequest, updateForeignFee, updateGroupRequest, updateNameRequest, voteOnPoll } from "./qortalRequests/get"; import { getData, storeData } from "./utils/chromeStorage"; import { executeEvent } from "./utils/events"; @@ -462,7 +462,7 @@ export const isRunningGateway = async ()=> { case "UPDATE_FOREIGN_FEE": { try { - const res = await updateForeignFee(request.payload); + const res = await updateForeignFee(request.payload, isFromExtension); event.source.postMessage({ requestId: request.requestId, action: request.action, @@ -502,7 +502,7 @@ export const isRunningGateway = async ()=> { case "SET_CURRENT_FOREIGN_SERVER": { try { - const res = await setCurrentForeignServer(request.payload); + const res = await setCurrentForeignServer(request.payload, isFromExtension); event.source.postMessage({ requestId: request.requestId, action: request.action, @@ -522,7 +522,7 @@ export const isRunningGateway = async ()=> { case "ADD_FOREIGN_SERVER": { try { - const res = await addForeignServer(request.payload); + const res = await addForeignServer(request.payload, isFromExtension); event.source.postMessage({ requestId: request.requestId, action: request.action, @@ -542,7 +542,7 @@ export const isRunningGateway = async ()=> { case "REMOVE_FOREIGN_SERVER": { try { - const res = await removeForeignServer(request.payload); + const res = await removeForeignServer(request.payload, isFromExtension); event.source.postMessage({ requestId: request.requestId, action: request.action, @@ -1282,6 +1282,70 @@ export const isRunningGateway = async ()=> { } break; } + case "MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA" : { + try { + const res = await multiPaymentWithPrivateData(request.payload, isFromExtension) + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } + case "TRANSFER_ASSET" : { + try { + const res = await transferAssetRequest(request.payload, isFromExtension) + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } + + case 'SIGN_FOREIGN_FEES': { + try { + const res = await signForeignFees(request.payload, isFromExtension); + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error.message, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } default: break; } diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 430b262..aa1e6f7 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -32,7 +32,12 @@ import { getBaseApi, buyName, cancelSellName, - sellName + sellName, + getAssetBalanceInfo, + getNameOrAddress, + getAssetInfo, + transferAsset, + getPublicKey } from "../background"; import { getNameInfo, uint8ArrayToObject } from "../backgroundFunctions/encryption"; import { showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; @@ -67,6 +72,11 @@ import utils from "../utils/utils"; import { RequestQueueWithPromise } from "../utils/queue/queue"; import ed2curve from "../deps/ed2curve"; import { Sha256 } from "asmcrypto.js"; +import { isValidBase64WithDecode } from "../utils/decode"; +import ShortUniqueId from "short-unique-id"; + +const uid = new ShortUniqueId({ length: 6 }); + export const requestQueueGetAtAddresses = new RequestQueueWithPromise(10); @@ -2367,7 +2377,7 @@ export const getTxActivitySummary = async (data) => { } }; - export const updateForeignFee = async (data) => { + export const updateForeignFee = async (data, isFromExtension) => { const isGateway = await isRunningGateway(); if (isGateway) { throw new Error("This action cannot be done through a public node"); @@ -2388,9 +2398,25 @@ export const getTxActivitySummary = async (data) => { } const { coin, type, value } = data; - const url = `/crosschain/${coin.toLowerCase()}/update${type}`; + + const resPermission = await getUserPermission( + { + text1: `Do you give this application to update foreign fees on your node?`, + text2: `type: ${type === 'feerequired' ? 'unlocking' : 'locking'}`, + text3: `value: ${value}`, + highlightedText: `Coin: ${coin}`, + }, + isFromExtension + ); + + const { accepted } = resPermission; + if (!accepted) { + throw new Error('User declined request'); + } + const url = `/crosschain/${coin.toLowerCase()}/update${type}`; + const valueStringified = JSON.stringify(+value); + - try { const endpoint = await createEndpoint(url); const response = await fetch(endpoint, { method: 'POST', @@ -2398,7 +2424,7 @@ export const getTxActivitySummary = async (data) => { Accept: '*/*', 'Content-Type': 'application/json', }, - body: JSON.stringify({ value }), + body: valueStringified, }); if (!response.ok) throw new Error('Failed to update foreign fee'); @@ -2412,9 +2438,7 @@ export const getTxActivitySummary = async (data) => { throw new Error(res.message); } return res; // Return full response here - } catch (error) { - throw new Error(error?.message || 'Error in update foreign fee'); - } + }; export const getServerConnectionHistory = async (data) => { @@ -2466,7 +2490,7 @@ export const getTxActivitySummary = async (data) => { } }; - export const setCurrentForeignServer = async (data) => { + export const setCurrentForeignServer = async (data, isFromExtension) => { const isGateway = await isRunningGateway(); if (isGateway) { throw new Error("This action cannot be done through a public node"); @@ -2488,6 +2512,22 @@ export const getTxActivitySummary = async (data) => { } const { coin, host, port, type } = data; + + const resPermission = await getUserPermission( + { + text1: `Do you give this application to set the current server?`, + text2: `type: ${type}`, + text3: `host: ${host}`, + highlightedText: `Coin: ${coin}`, + }, + isFromExtension + ); + + const { accepted } = resPermission; + if (!accepted) { + throw new Error('User declined request'); + } + const body = { hostName: host, port: port, @@ -2496,7 +2536,7 @@ export const getTxActivitySummary = async (data) => { const url = `/crosschain/${coin.toLowerCase()}/setcurrentserver`; - try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available const response = await fetch(endpoint, { method: 'POST', @@ -2521,13 +2561,11 @@ export const getTxActivitySummary = async (data) => { } return res; // Return the full response - } catch (error) { - throw new Error(error?.message || 'Error in set current server'); - } + }; - export const addForeignServer = async (data) => { + export const addForeignServer = async (data, isFromExtension) => { const isGateway = await isRunningGateway(); if (isGateway) { throw new Error("This action cannot be done through a public node"); @@ -2549,6 +2587,23 @@ export const getTxActivitySummary = async (data) => { } const { coin, host, port, type } = data; + + const resPermission = await getUserPermission( + { + text1: `Do you give this application to add a server?`, + text2: `type: ${type}`, + text3: `host: ${host}`, + highlightedText: `Coin: ${coin}`, + }, + isFromExtension + ); + + const { accepted } = resPermission; + if (!accepted) { + throw new Error('User declined request'); + } + + const body = { hostName: host, port: port, @@ -2557,7 +2612,7 @@ export const getTxActivitySummary = async (data) => { const url = `/crosschain/${coin.toLowerCase()}/addserver`; - try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available const response = await fetch(endpoint, { method: 'POST', @@ -2582,12 +2637,10 @@ export const getTxActivitySummary = async (data) => { } return res; // Return the full response - } catch (error) { - throw new Error(error.message || 'Error in adding server'); - } + }; - export const removeForeignServer = async (data) => { + export const removeForeignServer = async (data, isFromExtension) => { const isGateway = await isRunningGateway(); if (isGateway) { throw new Error("This action cannot be done through a public node"); @@ -2609,6 +2662,21 @@ export const getTxActivitySummary = async (data) => { } const { coin, host, port, type } = data; + + const resPermission = await getUserPermission( + { + text1: `Do you give this application to remove a server?`, + text2: `type: ${type}`, + text3: `host: ${host}`, + highlightedText: `Coin: ${coin}`, + }, + isFromExtension + ); + + const { accepted } = resPermission; + if (!accepted) { + throw new Error('User declined request'); + } const body = { hostName: host, port: port, @@ -2617,7 +2685,7 @@ export const getTxActivitySummary = async (data) => { const url = `/crosschain/${coin.toLowerCase()}/removeserver`; - try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available const response = await fetch(endpoint, { method: 'POST', @@ -2642,9 +2710,7 @@ export const getTxActivitySummary = async (data) => { } return res; // Return the full response - } catch (error) { - throw new Error(error?.message || 'Error in removing server'); - } + }; export const getDaySummary = async () => { @@ -3101,7 +3167,34 @@ export const sendCoin = async (data, isFromExtension) => { }; +function calculateFeeFromRate(feePerKb, sizeInBytes) { + return (feePerKb / 1000) * sizeInBytes; +} +const getBuyingFees = async (foreignBlockchain) => { + const ticker = sellerForeignFee[foreignBlockchain].ticker; + if (!ticker) throw new Error('invalid foreign blockchain'); + const unlockFee = await getForeignFee({ + coin: ticker, + type: 'feerequired', + }); + const lockFee = await getForeignFee({ + coin: ticker, + type: 'feekb', + }); + return { + ticker: ticker, + lock: { + sats: lockFee, + fee: lockFee / QORT_DECIMALS, + }, + unlock: { + sats: unlockFee, + fee: unlockFee / QORT_DECIMALS, + byteFee300: calculateFeeFromRate(+unlockFee, 300) / QORT_DECIMALS, + }, + }; +}; export const createBuyOrder = async (data, isFromExtension) => { @@ -3141,6 +3234,8 @@ export const createBuyOrder = async (data, isFromExtension) => { const crosschainAtInfo = await Promise.all(atPromises); try { + const buyingFees = await getBuyingFees(foreignBlockchain); + const resPermission = await getUserPermission({ text1: "Do you give this application permission to perform a buy order?", text2: `${atAddresses?.length}${" "} @@ -3154,10 +3249,46 @@ const crosschainAtInfo = await Promise.all(atPromises); return latest + +cur?.expectedForeignAmount; }, 0) )} - ${` ${crosschainAtInfo?.[0]?.foreignBlockchain}`}`, + ${` ${buyingFees.ticker}`}`, highlightedText: `Is using public node: ${isGateway}`, fee: '', - foreignFee: `${sellerForeignFee[foreignBlockchain].value} ${sellerForeignFee[foreignBlockchain].ticker}` + html: ` +
+ + +
+
Total Unlocking Fee:
+
${(+buyingFees?.unlock?.byteFee300 * atAddresses?.length)?.toFixed(8)} ${buyingFees.ticker}
+
+ This fee is an estimate based on ${atAddresses?.length} ${atAddresses?.length > 1 ? 'orders' : 'order'} at a 300 byte cost of ${buyingFees?.unlock?.byteFee300?.toFixed(8)} +
+ +
Total Locking Fee:
+
${+buyingFees?.unlock.fee.toFixed(8)} ${buyingFees.ticker} per kb
+ +
+
+ `, }, isFromExtension); const { accepted } = resPermission; if (accepted) { @@ -4804,4 +4935,432 @@ export const buyNameRequest = async (data, isFromExtension) => { } else { throw new Error("User declined request"); } +}; + +export const multiPaymentWithPrivateData = async (data, isFromExtension) => { + const requiredFields = ["payments", "assetId"]; + requiredFields.forEach((field) => { + if (data[field] === undefined || data[field] === null) { + throw new Error(`Missing required field: ${field}`); + } + }); + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const privateKey = parsedData.privateKey; + const userPublicKey = parsedData.publicKey + const {fee: paymentFee} = await getFee("TRANSFER_ASSET"); + const {fee: arbitraryFee} = await getFee("ARBITRARY"); + + let name = null + const payments = data.payments; + const assetId = data.assetId + const pendingTransactions = [] + const pendingAdditionalArbitraryTxs = [] + const additionalArbitraryTxsWithoutPayment = data?.additionalArbitraryTxsWithoutPayment || [] + let totalAmount = 0 + let fee = 0 + for (const payment of payments) { + const paymentRefId = uid.rnd(); + const requiredFieldsPayment = ["recipient", "amount"]; + + for (const field of requiredFieldsPayment) { + if (!payment[field]) { + throw new Error(`Missing required field: ${field}`); + } + } + + const confirmReceiver = await getNameOrAddress(payment.recipient); + if (confirmReceiver.error) { + throw new Error("Invalid receiver address or name"); + } + const receiverPublicKey = await getPublicKey(confirmReceiver) + + const amount = +payment.amount.toFixed(8) + + pendingTransactions.push({ + type: "PAYMENT", + recipientAddress: confirmReceiver, + amount: amount, + paymentRefId, + }); + + fee = fee + +paymentFee; + totalAmount = totalAmount + amount; + + if (payment.arbitraryTxs && payment.arbitraryTxs.length > 0) { + for (const arbitraryTx of payment.arbitraryTxs) { + const requiredFieldsArbitraryTx = ["service", "identifier", "base64"]; + + for (const field of requiredFieldsArbitraryTx) { + if (!arbitraryTx[field]) { + throw new Error(`Missing required field: ${field}`); + } + } + + if (!name) { + const getName = await getNameInfo(); + if (!getName) throw new Error("Name needed to publish"); + name = getName; + } + + const isValid = isValidBase64WithDecode(arbitraryTx.base64); + if (!isValid) throw new Error("Invalid base64 data"); + if(!arbitraryTx?.service?.includes('_PRIVATE')) throw new Error('Please use a PRIVATE service') + const additionalPublicKeys = arbitraryTx?.additionalPublicKeys || [] + pendingTransactions.push({ + type: "ARBITRARY", + identifier: arbitraryTx.identifier, + service: arbitraryTx.service, + base64: arbitraryTx.base64, + description: arbitraryTx?.description || "", + paymentRefId, + publicKeys: [receiverPublicKey, ...additionalPublicKeys] + }); + + fee = fee + +arbitraryFee; + } + } + } + + if (additionalArbitraryTxsWithoutPayment && additionalArbitraryTxsWithoutPayment.length > 0) { + for (const arbitraryTx of additionalArbitraryTxsWithoutPayment) { + const requiredFieldsArbitraryTx = ["service", "identifier", "base64"]; + + for (const field of requiredFieldsArbitraryTx) { + if (!arbitraryTx[field]) { + throw new Error(`Missing required field: ${field}`); + } + } + + if (!name) { + const getName = await getNameInfo(); + if (!getName) throw new Error("Name needed to publish"); + name = getName; + } + + const isValid = isValidBase64WithDecode(arbitraryTx.base64); + if (!isValid) throw new Error("Invalid base64 data"); + if(!arbitraryTx?.service?.includes('_PRIVATE')) throw new Error('Please use a PRIVATE service') + const additionalPublicKeys = arbitraryTx?.additionalPublicKeys || [] + pendingAdditionalArbitraryTxs.push({ + type: "ARBITRARY", + identifier: arbitraryTx.identifier, + service: arbitraryTx.service, + base64: arbitraryTx.base64, + description: arbitraryTx?.description || "", + publicKeys: additionalPublicKeys + }); + + fee = fee + +arbitraryFee; + } + } + + + + if(!name) throw new Error('A name is needed to publish') + const balance = await getBalanceInfo(); + + if(+balance < fee) throw new Error('Your QORT balance is insufficient') +const assetBalance = await getAssetBalanceInfo(assetId) + const assetInfo = await getAssetInfo(assetId) + if(assetBalance < totalAmount) throw new Error('Your asset balance is insufficient') + + const resPermission = await getUserPermission( + { + text1: "Do you give this application permission to make the following payments and publishes?", + text2: `Asset used in payments: ${assetInfo.name}`, + html: ` +
+ + + ${pendingTransactions. + filter((item)=> item.type === 'PAYMENT').map( + (payment) => ` +
+
Recipient: ${ + payment.recipientAddress + }
+
Amount: ${payment.amount}
+
` + ) + .join("")} + ${[...pendingTransactions, ...pendingAdditionalArbitraryTxs]. + filter((item)=> item.type === 'ARBITRARY').map( + (arbitraryTx) => ` +
+
Service: ${ + arbitraryTx.service + }
+
Name: ${name}
+
Identifier: ${ + arbitraryTx.identifier + }
+
` + ) + .join("")} +
+ + `, + highlightedText: `Total Amount: ${totalAmount}`, + fee: fee + }, + isFromExtension + ); + const { accepted, checkbox1 = false } = resPermission; + if (!accepted) { + throw new Error("User declined request"); + } + + + + + // const failedTxs = [] + const paymentsDone = { + + } + + const transactionsDone = [] + + + for (const transaction of pendingTransactions) { + const type = transaction.type; + + if (type === "PAYMENT") { + const makePayment = await retryTransaction( + transferAsset, + [{ amount: transaction.amount, assetId, recipient: transaction.recipientAddress }], true + ); + if (makePayment) { + transactionsDone.push(makePayment?.signature); + if (transaction.paymentRefId) { + paymentsDone[transaction.paymentRefId] = makePayment + } + } + } + else if (type === "ARBITRARY" && paymentsDone[transaction.paymentRefId]) { + const objectToEncrypt = { + data: transaction.base64, + payment: paymentsDone[transaction.paymentRefId], + }; + + const toBase64 = await retryTransaction(objectToBase64, [objectToEncrypt], true); + + if (!toBase64) continue; // Skip if encryption fails + + const encryptDataResponse = await retryTransaction(encryptDataGroup, [ + { + data64: toBase64, + publicKeys: transaction.publicKeys, + privateKey, + userPublicKey, + }, + ], true); + + if (!encryptDataResponse) continue; // Skip if encryption fails + + const resPublish = await retryTransaction(publishData, [ + { + registeredName: encodeURIComponent(name), + file: encryptDataResponse, + service: transaction.service, + identifier: encodeURIComponent(transaction.identifier), + uploadType: "file", + description: transaction?.description, + isBase64: true, + apiVersion: 2, + withFee: true, + }, + ], true); + + if (resPublish?.signature) { + transactionsDone.push(resPublish?.signature); + } + } + } + + for (const transaction of pendingAdditionalArbitraryTxs) { + + const objectToEncrypt = { + data: transaction.base64, + }; + + const toBase64 = await retryTransaction(objectToBase64, [objectToEncrypt], true); + + if (!toBase64) continue; // Skip if encryption fails + + const encryptDataResponse = await retryTransaction(encryptDataGroup, [ + { + data64: toBase64, + publicKeys: transaction.publicKeys, + privateKey, + userPublicKey, + }, + ], true); + + if (!encryptDataResponse) continue; // Skip if encryption fails + + const resPublish = await retryTransaction(publishData, [ + { + registeredName: encodeURIComponent(name), + file: encryptDataResponse, + service: transaction.service, + identifier: encodeURIComponent(transaction.identifier), + uploadType: "file", + description: transaction?.description, + isBase64: true, + apiVersion: 2, + withFee: true, + }, + ], true); + + if (resPublish?.signature) { + transactionsDone.push(resPublish?.signature); + } + + } + + return transactionsDone +}; + + +export const transferAssetRequest = async (data, isFromExtension) => { + const requiredFields = ["amount", "assetId", "recipient"]; + requiredFields.forEach((field) => { + if (data[field] === undefined || data[field] === null) { + throw new Error(`Missing required field: ${field}`); + } + }); + const amount = data.amount + const assetId = data.assetId + const recipient = data.recipient + + + const {fee} = await getFee("TRANSFER_ASSET"); + const balance = await getBalanceInfo(); + + if(+balance < +fee) throw new Error('Your QORT balance is insufficient') + const assetBalance = await getAssetBalanceInfo(assetId) + if(assetBalance < amount) throw new Error('Your asset balance is insufficient') + const confirmReceiver = await getNameOrAddress(recipient); + if (confirmReceiver.error) { + throw new Error("Invalid receiver address or name"); + } + const assetInfo = await getAssetInfo(assetId) + const resPermission = await getUserPermission( + { + text1: `Do you give this application permission to transfer the following asset?`, + text2: `Asset: ${assetInfo?.name}`, + highlightedText: `Amount: ${amount}`, + fee: fee + }, + isFromExtension + ); + + const { accepted } = resPermission; + if (!accepted) { + throw new Error("User declined request"); + } + const res = await transferAsset({amount, recipient: confirmReceiver, assetId}) + return res +} + +export const signForeignFees = async (data, isFromExtension) => { + const resPermission = await getUserPermission( + { + text1: `Do you give this application permission to sign the required fees for all your trade offers?`, + }, + isFromExtension + ); + const { accepted } = resPermission; + if (accepted) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + + const unsignedFeesUrl = await createEndpoint( + `/crosschain/unsignedfees/${address}` + ); + + const unsignedFeesResponse = await fetch(unsignedFeesUrl); + + const unsignedFees = await unsignedFeesResponse.json(); + + const signedFees = []; + + unsignedFees.forEach((unsignedFee) => { + const unsignedDataDecoded = Base58.decode(unsignedFee.data); + + const signature = nacl.sign.detached( + unsignedDataDecoded, + keyPair.privateKey + ); + + const signedFee = { + timestamp: unsignedFee.timestamp, + data: `${Base58.encode(signature)}`, + atAddress: unsignedFee.atAddress, + fee: unsignedFee.fee, + }; + + signedFees.push(signedFee); + }); + + const signedFeesUrl = await createEndpoint(`/crosschain/signedfees`); + + await fetch(signedFeesUrl, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: `${JSON.stringify(signedFees)}`, + }); + + return true; + } else { + throw new Error('User declined request'); + } }; \ No newline at end of file diff --git a/src/transactions/TransferAssetTransaction.ts b/src/transactions/TransferAssetTransaction.ts new file mode 100644 index 0000000..9d0bedb --- /dev/null +++ b/src/transactions/TransferAssetTransaction.ts @@ -0,0 +1,35 @@ +// @ts-nocheck + +import { QORT_DECIMALS } from '../constants/constants' +import TransactionBase from './TransactionBase' + +export default class TransferAssetTransaction extends TransactionBase { + constructor() { + super() + this.type = 12 + } + + set recipient(recipient) { + this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient) + } + + set amount(amount) { + this._amount = Math.round(amount * QORT_DECIMALS) + this._amountBytes = this.constructor.utils.int64ToBytes(this._amount) + } + + set assetId(assetId) { + this._assetId = this.constructor.utils.int64ToBytes(assetId) + } + + get params() { + const params = super.params + params.push( + this._recipient, + this._assetId, + this._amountBytes, + this._feeBytes + ) + return params + } +} diff --git a/src/transactions/transactions.ts b/src/transactions/transactions.ts index cb0f9c3..268b631 100644 --- a/src/transactions/transactions.ts +++ b/src/transactions/transactions.ts @@ -24,6 +24,7 @@ import UpdateGroupTransaction from './UpdateGroupTransaction.js' import SellNameTransacion from './SellNameTransacion.js' import CancelSellNameTransacion from './CancelSellNameTransacion.js' import BuyNameTransacion from './BuyNameTransacion.js' +import TransferAssetTransaction from './TransferAssetTransaction.js' export const transactionTypes = { 3: RegisterNameTransaction, @@ -34,6 +35,7 @@ export const transactionTypes = { 7: BuyNameTransacion, 8: CreatePollTransaction, 9: VoteOnPollTransaction, + 12: TransferAssetTransaction, 16: DeployAtTransaction, 18: ChatTransaction, 181: GroupChatTransaction, diff --git a/src/utils/decode.ts b/src/utils/decode.ts index 3123810..06fdb2b 100644 --- a/src/utils/decode.ts +++ b/src/utils/decode.ts @@ -13,4 +13,19 @@ export function decodeIfEncoded(input) { // Return input as-is if not URI-encoded return input; - } \ No newline at end of file + } + + export const isValidBase64 = (str: string): boolean => { + if (typeof str !== "string" || str.length % 4 !== 0) return false; + + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + return base64Regex.test(str); + }; + + export const isValidBase64WithDecode = (str: string): boolean => { + try { + return isValidBase64(str) && Boolean(atob(str)); + } catch { + return false; + } + }; \ No newline at end of file