diff --git a/src/_tests/integration/testcontract.integration.test.ts b/src/_tests/integration/testcontract.integration.test.ts index ff9ff4c7..e2e53adc 100644 --- a/src/_tests/integration/testcontract.integration.test.ts +++ b/src/_tests/integration/testcontract.integration.test.ts @@ -491,7 +491,7 @@ describe('Test Contract Fallback', function () { const { name, address, abi } = test; const send = test[group]; - const contract = new Contract(address, abi, provider); + const contract = new Contract(address, abi, provider as ContractRunner); it(`test contract fallback checks: ${group} - ${name}`, async function () { const func = async function () { if (abi.length === 0) { diff --git a/src/providers/abstract-provider.ts b/src/providers/abstract-provider.ts index c8660253..07484997 100644 --- a/src/providers/abstract-provider.ts +++ b/src/providers/abstract-provider.ts @@ -14,7 +14,7 @@ import { computeAddress, resolveAddress, formatMixedCaseChecksumAddress } from '../address/index.js'; import { Shard, toShard, toZone, Zone } from '../constants/index.js'; import { TxInput, TxOutput } from '../transaction/index.js'; -import { Outpoint } from '../transaction/utxo.js'; +import { Outpoint, TxInputJson, TxOutputJson } from '../transaction/utxo.js'; import { hexlify, isHexString, @@ -494,15 +494,20 @@ export interface QuaiPerformActionTransaction extends QuaiPreparedTransactionReq */ // todo: write docs for this export interface QiPerformActionTransaction extends QiPreparedTransactionRequest { + /** + * The transaction type. Always 2 for UTXO transactions. + */ + txType: number; + /** * The `inputs` of the UTXO transaction. */ - inputs?: Array; + txIn: Array; /** * The `outputs` of the UTXO transaction. */ - outputs?: Array; + txOut: Array; [key: string]: any; } @@ -534,6 +539,11 @@ export type PerformActionRequest = transaction: PerformActionTransaction; zone?: Zone; } + | { + method: 'estimateFeeForQi'; + transaction: QiPerformActionTransaction; + zone?: Zone; + } | { method: 'createAccessList'; transaction: PerformActionTransaction; @@ -1507,6 +1517,18 @@ export class AbstractProvider implements Provider { ); } + async estimateFeeForQi(_tx: QiPerformActionTransaction): Promise { + const zone = await this.zoneFromAddress(addressFromTransactionRequest(_tx)); + return getBigInt( + await this.#perform({ + method: 'estimateFeeForQi', + transaction: _tx, + zone: zone, + }), + '%response', + ); + } + async createAccessList(_tx: TransactionRequest): Promise { let tx = this._getTransactionRequest(_tx); if (isPromise(tx)) { diff --git a/src/providers/provider-jsonrpc.ts b/src/providers/provider-jsonrpc.ts index ee8f056a..057feee4 100644 --- a/src/providers/provider-jsonrpc.ts +++ b/src/providers/provider-jsonrpc.ts @@ -1238,6 +1238,13 @@ export abstract class JsonRpcApiProvider extends AbstractProvi }; } + case 'estimateFeeForQi': { + return { + method: 'quai_estimateFeeForQi', + args: [req.transaction], + }; + } + case 'createAccessList': { return { method: 'quai_createAccessList', diff --git a/src/providers/provider.ts b/src/providers/provider.ts index 3e54c805..a2e248c7 100644 --- a/src/providers/provider.ts +++ b/src/providers/provider.ts @@ -19,7 +19,7 @@ import type { AccessList, AccessListish } from '../transaction/index.js'; import type { ContractRunner } from '../contract/index.js'; import type { Network } from './network.js'; -import type { Outpoint } from '../transaction/utxo.js'; +import type { Outpoint, TxInputJson } from '../transaction/utxo.js'; import type { TxInput, TxOutput } from '../transaction/utxo.js'; import type { Zone, Shard } from '../constants/index.js'; import type { txpoolContentResponse, txpoolInspectResponse } from './txpool.js'; @@ -56,6 +56,7 @@ import { QiTransactionLike } from '../transaction/qi-transaction.js'; import { QuaiTransactionLike } from '../transaction/quai-transaction.js'; import { toShard, toZone } from '../constants/index.js'; import { getZoneFromNodeLocation, getZoneForAddress } from '../utils/shards.js'; +import { QiPerformActionTransaction } from './abstract-provider.js'; /** * Get the value if it is not null or undefined. @@ -146,7 +147,12 @@ export function addressFromTransactionRequest(tx: TransactionRequest): AddressLi return tx.from; } if ('txInputs' in tx && !!tx.txInputs) { - return computeAddress(tx.txInputs[0].pubkey); + const inputs = tx.txInputs as TxInput[]; + return computeAddress(inputs[0].pubkey); + } + if ('txIn' in tx && !!tx.txIn) { + const inputs = tx.txIn as TxInputJson[]; + return computeAddress(inputs[0].pubkey); } if ('to' in tx && !!tx.to) { return tx.to as AddressLike; @@ -2857,6 +2863,14 @@ export interface Provider extends ContractRunner, EventEmitterable; + /** + * Estimate the fee for a Qi transaction. + * + * @param {QiPerformActionTransaction} tx - The transaction to estimate the fee for. + * @returns {Promise} A promise resolving to the estimated fee. + */ + estimateFeeForQi(tx: QiPerformActionTransaction): Promise; + /** * Required for populating access lists for state mutating calls * diff --git a/src/transaction/utxo.ts b/src/transaction/utxo.ts index ba69ebfc..5a7a40f2 100644 --- a/src/transaction/utxo.ts +++ b/src/transaction/utxo.ts @@ -57,6 +57,22 @@ export type TxOutput = { lock?: string; }; +type PreviousOutpointJson = { + txHash: string; + index: string; +}; + +export type TxInputJson = { + previousOutpoint: PreviousOutpointJson; + pubkey: string; +}; + +export type TxOutputJson = { + address: string; + denomination: string; + lock?: string; +}; + /** * List of supported Qi denominations. * diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index 14eb915a..e1b38589 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -9,7 +9,7 @@ import { import { HDNodeWallet } from './hdnodewallet.js'; import { QiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; import { computeAddress, isQiAddress } from '../address/index.js'; -import { getBytes, getZoneForAddress, hexlify } from '../utils/index.js'; +import { getBytes, getZoneForAddress, hexlify, toQuantity } from '../utils/index.js'; import { TransactionLike, QiTransaction, TxInput, FewestCoinSelector } from '../transaction/index.js'; import { MuSigFactory } from '@brandonblack/musig'; import { schnorr } from '@noble/curves/secp256k1'; @@ -23,6 +23,7 @@ import { bs58check } from './bip32/crypto.js'; import { type BIP32API, HDNodeBIP32Adapter } from './bip32/types.js'; import ecc from '@bitcoinerlab/secp256k1'; import { SelectedCoinsResult } from '../transaction/abstract-coinselector.js'; +import { QiPerformActionTransaction } from '../providers/abstract-provider.js'; /** * @property {Outpoint} outpoint - The outpoint object. @@ -550,43 +551,71 @@ export class QiHDWallet extends AbstractHDWallet { throw new Error('Missing public key for input address'); } - const chainId = (await this.provider.getNetwork()).chainId; - let tx = await this.prepareTransaction( - selection, - inputPubKeys.map((pubkey) => pubkey!), - sendAddresses, - changeAddresses, - Number(chainId), - ); + let attempts = 0; + let finalFee = 0n; + let satisfiedFeeEstimation = false; + const MAX_FEE_ESTIMATION_ATTEMPTS = 5; + + while (attempts < MAX_FEE_ESTIMATION_ATTEMPTS) { + const feeEstimationTx = this.prepareFeeEstimationTransaction( + selection, + inputPubKeys.map((pubkey) => pubkey!), + sendAddresses, + changeAddresses, + ); - const gasLimit = await this.provider.estimateGas(tx); - const gasPrice = denominations[1]; // 0.005 Qi - const minerTip = (gasLimit * gasPrice) / 100n; // 1% extra as tip - // const feeData = await this.provider.getFeeData(originZone, true); - // const conversionRate = await this.provider.getLatestQuaiRate(originZone, feeData.gasPrice!); + finalFee = await this.provider.estimateFeeForQi(feeEstimationTx); + + // Get new selection with updated fee + selection = fewestCoinSelector.performSelection(spendTarget, finalFee); + + // Determine if new addresses are needed for the change outputs + const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; + if (changeAddressesNeeded > 0) { + // Need more change addresses + const newChangeAddresses = await getChangeAddressesForOutputs(changeAddressesNeeded); + changeAddresses.push(...newChangeAddresses); + } else if (changeAddressesNeeded < 0) { + // Have extra change addresses, remove the addresses starting from the end + // TODO: Set the status of the addresses to UNUSED in _addressesMap. This fine for now as it will be fixed during next sync + changeAddresses.splice(changeAddressesNeeded); + } - // 5.6 Calculate total fee for the transaction using the gasLimit, gasPrice, and minerTip - const totalFee = gasLimit * gasPrice + minerTip; + // Determine if new addresses are needed for the spend outputs + const spendAddressesNeeded = selection.spendOutputs.length - sendAddresses.length; + if (spendAddressesNeeded > 0) { + // Need more send addresses + const newSendAddresses = await getDestinationAddresses(spendAddressesNeeded); + sendAddresses.push(...newSendAddresses); + } else if (spendAddressesNeeded < 0) { + // Have extra send addresses, remove the excess + // TODO: Set the status of the addresses to UNUSED in _addressesMap. This fine for now as it will be fixed during next sync + sendAddresses.splice(spendAddressesNeeded); + } - // Get new selection with fee - selection = fewestCoinSelector.performSelection(spendTarget, totalFee); + inputPubKeys = selection.inputs.map((input) => this.locateAddressInfo(input.address)?.pubKey); - // Determine if new addresses are needed for the change and spend outputs - const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; - if (changeAddressesNeeded > 0) { - const outpusChangeAddresses = await getChangeAddressesForOutputs(changeAddressesNeeded); - changeAddresses.push(...outpusChangeAddresses); - } + // Calculate total new outputs needed (absolute value) + const totalNewOutputsNeeded = Math.abs(changeAddressesNeeded) + Math.abs(spendAddressesNeeded); - const spendAddressesNeeded = selection.spendOutputs.length - sendAddresses.length; - if (spendAddressesNeeded > 0) { - const newSendAddresses = await getDestinationAddresses(spendAddressesNeeded); - sendAddresses.push(...newSendAddresses); + // If we need 5 or fewer new outputs, we can break the loop + if ((changeAddressesNeeded <= 0 && spendAddressesNeeded <= 0) || totalNewOutputsNeeded <= 5) { + finalFee *= 3n; // Increase the fee 3x to ensure it's accepted + satisfiedFeeEstimation = true; + break; + } + + attempts++; } - inputPubKeys = selection.inputs.map((input) => this.locateAddressInfo(input.address)?.pubKey); + // If we didn't satisfy the fee estimation, increase the fee 10x to ensure it's accepted + if (!satisfiedFeeEstimation) { + finalFee *= 10n; + } - tx = await this.prepareTransaction( + // Proceed with creating and signing the transaction + const chainId = (await this.provider.getNetwork()).chainId; + const tx = await this.prepareTransaction( selection, inputPubKeys.map((pubkey) => pubkey!), sendAddresses, @@ -706,6 +735,41 @@ export class QiHDWallet extends AbstractHDWallet { return tx; } + private prepareFeeEstimationTransaction( + selection: SelectedCoinsResult, + inputPubKeys: string[], + sendAddresses: string[], + changeAddresses: string[], + ): QiPerformActionTransaction { + const txIn = selection.inputs.map((input, index) => ({ + previousOutpoint: { txHash: input.txhash!, index: toQuantity(input.index!) }, + pubkey: inputPubKeys[index], + })); + + // 5.3 Create the "sender" outputs + const senderOutputs = selection.spendOutputs.map((output, index) => ({ + address: sendAddresses[index], + denomination: output.denomination, + })); + + // 5.4 Create the "change" outputs + const changeOutputs = selection.changeOutputs.map((output, index) => ({ + address: changeAddresses[index], + denomination: output.denomination, + })); + + const txOut = [...senderOutputs, ...changeOutputs].map((output) => ({ + address: output.address, + denomination: toQuantity(output.denomination!), + })); + + return { + txType: 2, + txIn, + txOut, + }; + } + /** * Checks the status of pending outpoints and updates the wallet's UTXO set accordingly. *