diff --git a/src/App.tsx b/src/App.tsx index 558cd01..8a36a7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2074,6 +2074,8 @@ function App() { lineHeight: 1.2, maxWidth: '90%', textAlign: 'center', + fontSize: '16px', + marginBottom: '10px', }} > {messageQortalRequest?.text1} diff --git a/src/background.ts b/src/background.ts index 93a5e28..dca7e62 100644 --- a/src/background.ts +++ b/src/background.ts @@ -937,6 +937,29 @@ export async function getBalanceInfo() { 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 getLTCBalance() { const wallet = await getSaveWallet(); let _url = `${buyTradeNodeBaseUrl}/crosschain/ltc/walletbalance`; @@ -2288,6 +2311,34 @@ export async function kickFromGroup({ return res; } +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 createGroup({ groupName, groupDescription, diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx index 17ce580..b1736a2 100644 --- a/src/components/Apps/useQortalMessageListener.tsx +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -254,6 +254,8 @@ export const listOfAllQortalRequests = [ 'CANCEL_SELL_NAME', 'BUY_NAME', 'SIGN_FOREIGN_FEES', + 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'TRANSFER_ASSET', ]; export const UIQortalRequests = [ @@ -315,6 +317,8 @@ export const UIQortalRequests = [ 'CANCEL_SELL_NAME', 'BUY_NAME', 'SIGN_FOREIGN_FEES', + 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'TRANSFER_ASSET', ]; async function retrieveFileFromIndexedDB(fileId) { diff --git a/src/qortalRequests.ts b/src/qortalRequests.ts index 4951a0d..7a0d4e8 100644 --- a/src/qortalRequests.ts +++ b/src/qortalRequests.ts @@ -62,6 +62,8 @@ import { sellNameRequest, cancelSellNameRequest, signForeignFees, + multiPaymentWithPrivateData, + transferAssetRequest, } from './qortalRequests/get'; import { getData, storeData } from './utils/chromeStorage'; import { executeEvent } from './utils/events'; @@ -1756,6 +1758,63 @@ function setupMessageListenerQortalRequest() { } 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 'BUY_NAME': { try { const res = await buyNameRequest(request.payload, isFromExtension); diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 5f71707..d7b040e 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -32,6 +32,11 @@ import { cancelSellName, buyName, getBaseApi, + getAssetBalanceInfo, + getNameOrAddress, + getAssetInfo, + getPublicKey, + transferAsset, } from '../background'; import { getNameInfo, @@ -50,6 +55,7 @@ import { QORT_DECIMALS } from '../constants/constants'; import Base58 from '../deps/Base58'; import ed2curve from '../deps/ed2curve'; import nacl from '../deps/nacl-fast'; + import { base64ToUint8Array, createSymmetricKeyAndNonce, @@ -78,6 +84,10 @@ import { fileToBase64 } from '../utils/fileReading'; import { mimeToExtensionMap } from '../utils/memeTypes'; import { RequestQueueWithPromise } from '../utils/queue/queue'; import utils from '../utils/utils'; +import ShortUniqueId from 'short-unique-id'; +import { isValidBase64WithDecode } from '../utils/decode'; + +const uid = new ShortUniqueId({ length: 6 }); export const requestQueueGetAtAddresses = new RequestQueueWithPromise(10); @@ -4988,3 +4998,398 @@ export const signForeignFees = async (data, isFromExtension) => { 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; +}; 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 f989f3c..a117560 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 = { @@ -35,6 +36,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