Merge pull request #38 from Qortal/feature/asset-qortalrequests

Feature/asset qortalrequests
This commit is contained in:
Phillip 2025-05-01 12:33:12 +03:00 committed by GitHub
commit 33a8ecd4a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 575 additions and 1 deletions

View File

@ -2074,6 +2074,8 @@ function App() {
lineHeight: 1.2,
maxWidth: '90%',
textAlign: 'center',
fontSize: '16px',
marginBottom: '10px',
}}
>
{messageQortalRequest?.text1}

View File

@ -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,

View File

@ -253,6 +253,8 @@ export const listOfAllQortalRequests = [
'SELL_NAME',
'CANCEL_SELL_NAME',
'BUY_NAME',
'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA',
'TRANSFER_ASSET',
];
export const UIQortalRequests = [
@ -313,6 +315,8 @@ export const UIQortalRequests = [
'SELL_NAME',
'CANCEL_SELL_NAME',
'BUY_NAME',
'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA',
'TRANSFER_ASSET',
];
async function retrieveFileFromIndexedDB(fileId) {

View File

@ -61,6 +61,8 @@ import {
buyNameRequest,
sellNameRequest,
cancelSellNameRequest,
multiPaymentWithPrivateData,
transferAssetRequest,
} from './qortalRequests/get';
import { getData, storeData } from './utils/chromeStorage';
import { executeEvent } from './utils/events';
@ -1755,6 +1757,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);

View File

@ -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);
@ -4923,3 +4933,399 @@ export const buyNameRequest = 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: `
<div style="max-height: 30vh; overflow-y: auto;">
<style>
body {
background-color: #121212;
color: #e0e0e0;
}
.resource-container {
display: flex;
flex-direction: column;
border: 1px solid #444;
padding: 16px;
margin: 8px 0;
border-radius: 8px;
background-color: #1e1e1e;
}
.resource-detail {
margin-bottom: 8px;
}
.resource-detail span {
font-weight: bold;
color: #bb86fc;
}
@media (min-width: 600px) {
.resource-container {
flex-direction: row;
flex-wrap: wrap;
}
.resource-detail {
flex: 1 1 45%;
margin-bottom: 0;
padding: 4px 0;
}
}
</style>
${pendingTransactions
.filter((item) => item.type === 'PAYMENT')
.map(
(payment) => `
<div class="resource-container">
<div class="resource-detail"><span>Recipient:</span> ${
payment.recipientAddress
}</div>
<div class="resource-detail"><span>Amount:</span> ${payment.amount}</div>
</div>`
)
.join('')}
${[...pendingTransactions, ...pendingAdditionalArbitraryTxs]
.filter((item) => item.type === 'ARBITRARY')
.map(
(arbitraryTx) => `
<div class="resource-container">
<div class="resource-detail"><span>Service:</span> ${
arbitraryTx.service
}</div>
<div class="resource-detail"><span>Name:</span> ${name}</div>
<div class="resource-detail"><span>Identifier:</span> ${
arbitraryTx.identifier
}</div>
</div>`
)
.join('')}
</div>
`,
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;
};

View File

@ -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
}
}

View File

@ -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,

View File

@ -13,4 +13,19 @@ export function decodeIfEncoded(input) {
// Return input as-is if not URI-encoded
return input;
}
}
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;
}
};