From b852a26fb0383df06e687137af0f2e342fbc8e9f Mon Sep 17 00:00:00 2001 From: Riley Stephens Date: Mon, 7 Oct 2024 19:11:47 -0500 Subject: [PATCH] Qi gas fee (#309) * refactor coin selection logic to work with denomination indices * refactor coinselector to work with UTXO objects * add new method signature for sendTransaction using paymentCodes * implement sendTransaction with paymentCode * update proto schema for Qi * WIP: add lock field and debug lines * Fix Qi tx submission * fix send Qi with musig * Remove redundent tx type population * Add qi tx fee and force denominating down for outputs * Fix signature decision in qi tx signing * Fix import without file ext * Export serialized wallet types * Remove console logs * Update external deps reference * Apply automatic changes * Apply automatic changes * Fix vulnerable dependency `rollup` * Apply automatic changes --------- Co-authored-by: Alejo Acosta Co-authored-by: rileystephens28 --- examples/wallets/qi-send.js | 80 ++++++ src/_tests/unit/coinselection.unit.test.ts | 267 ++++++++++++++++++-- src/encoding/protoc/proto_block.proto | 1 + src/encoding/protoc/proto_block.ts | 40 ++- src/encoding/protoc/proto_common.ts | 2 +- src/providers/abstract-provider.ts | 63 +++-- src/providers/provider-jsonrpc.ts | 19 +- src/providers/provider.ts | 3 +- src/quais.ts | 2 + src/signers/abstract-signer.ts | 6 +- src/transaction/abstract-coinselector.ts | 79 +----- src/transaction/abstract-transaction.ts | 5 + src/transaction/coinselector-fewest.ts | 277 +++++++++++++++------ src/transaction/index.ts | 1 + src/transaction/qi-transaction.ts | 32 +-- src/transaction/utxo.ts | 57 +++-- src/wallet/hdwallet.ts | 16 +- src/wallet/index.ts | 4 +- src/wallet/qi-hdwallet.ts | 232 ++++++++++++----- 19 files changed, 864 insertions(+), 322 deletions(-) create mode 100644 examples/wallets/qi-send.js diff --git a/examples/wallets/qi-send.js b/examples/wallets/qi-send.js new file mode 100644 index 00000000..d657fdc4 --- /dev/null +++ b/examples/wallets/qi-send.js @@ -0,0 +1,80 @@ +const quais = require('../../lib/commonjs/quais'); +require('dotenv').config(); + +// Descrepancy between our serialized data and go quais in that ours in inlcude extra data at the end -> 201406c186bf3b66571cfdd8c7d9336df2298e4d4a9a2af7fcca36fbdfb0b43459a41c45b6c8885dc1f828d44fd005572cbac4cd72dc598790429255d19ec32f7750e + +async function main() { + // Create provider + console.log('RPC URL: ', process.env.RPC_URL); + const provider = new quais.JsonRpcProvider(process.env.RPC_URL); + + // Create wallet and connect to provider + const mnemonic = quais.Mnemonic.fromPhrase(process.env.MNEMONIC); + const aliceQiWallet = quais.QiHDWallet.fromMnemonic(mnemonic); + aliceQiWallet.connect(provider); + + // Initialize Qi wallet + console.log('Initializing Alice wallet...'); + await aliceQiWallet.scan(quais.Zone.Cyprus1); + console.log('Alice wallet scan complete'); + console.log('Serializing Alice wallet...'); + const serializedWallet = aliceQiWallet.serialize(); + + const summary = { + 'Total Addresses': serializedWallet.addresses.length, + 'Change Addresses': serializedWallet.changeAddresses.length, + 'Gap Addresses': serializedWallet.gapAddresses.length, + 'Gap Change Addresses': serializedWallet.gapChangeAddresses.length, + Outpoints: serializedWallet.outpoints.length, + 'Coin Type': serializedWallet.coinType, + Version: serializedWallet.version, + }; + + console.log('Alice Wallet Summary:'); + console.table(summary); + + const addressTable = serializedWallet.addresses.map((addr) => ({ + PubKey: addr.pubKey, + Address: addr.address, + Index: addr.index, + Change: addr.change ? 'Yes' : 'No', + Zone: addr.zone, + })); + + console.log('\nAlice Wallet Addresses (first 10):'); + console.table(addressTable.slice(0, 10)); + + const outpointsInfoTable = serializedWallet.outpoints.map((outpoint) => ({ + Address: outpoint.address, + Denomination: outpoint.outpoint.denomination, + Index: outpoint.outpoint.index, + TxHash: outpoint.outpoint.txhash, + Zone: outpoint.zone, + Account: outpoint.account, + })); + + console.log('\nAlice Outpoints Info (first 10):'); + console.table(outpointsInfoTable.slice(0, 10)); + + console.log(`Generating Bob's wallet and payment code...`); + const bobMnemonic = quais.Mnemonic.fromPhrase( + 'innocent perfect bus miss prevent night oval position aspect nut angle usage expose grace juice', + ); + const bobQiWallet = quais.QiHDWallet.fromMnemonic(bobMnemonic); + const bobPaymentCode = await bobQiWallet.getPaymentCode(0); + console.log('Bob Payment code: ', bobPaymentCode); + + // Alice opens a channel to send Qi to Bob + aliceQiWallet.openChannel(bobPaymentCode, 'sender'); + + // Alice sends 1000 Qi to Bob + const tx = await aliceQiWallet.sendTransaction(bobPaymentCode, 750000, quais.Zone.Cyprus1, quais.Zone.Cyprus1); + console.log('Transaction sent: ', tx); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/_tests/unit/coinselection.unit.test.ts b/src/_tests/unit/coinselection.unit.test.ts index f961056c..9a30800b 100644 --- a/src/_tests/unit/coinselection.unit.test.ts +++ b/src/_tests/unit/coinselection.unit.test.ts @@ -1,16 +1,15 @@ import assert from 'assert'; import { FewestCoinSelector } from '../../transaction/coinselector-fewest.js'; -import { UTXO, denominations } from '../../transaction/utxo.js'; +import { UTXO, denominate, denominations } from '../../transaction/utxo.js'; const TEST_SPEND_ADDRESS = '0x00539bc2CE3eD0FD039c582CB700EF5398bB0491'; -const TEST_RECEIVE_ADDRESS = '0x02b9B1D30B6cCdc7d908B82739ce891463c3FA19'; -// Utility function to create UTXOs (adjust as necessary) +// Utility function to create UTXOs with specified denominations function createUTXOs(denominationIndices: number[]): UTXO[] { - return denominationIndices.map((index) => + return denominationIndices.map((index, idx) => UTXO.from({ - txhash: '0x0000000000000000000000000000000000000000000000000000000000000000', - index: 0, + txhash: `0x${String(idx).padStart(64, '0')}`, + index: idx, address: TEST_SPEND_ADDRESS, denomination: index, }), @@ -21,7 +20,7 @@ describe('FewestCoinSelector', function () { describe('Selecting valid UTXOs', function () { it('selects a single UTXO that exactly matches the target amount', function () { const availableUTXOs = createUTXOs([1, 2, 3]); // .065 Qi - const targetSpend = { value: denominations[3], address: TEST_RECEIVE_ADDRESS }; // .05 Qi + const targetSpend = denominations[3]; // .05 Qi const selector = new FewestCoinSelector(availableUTXOs); const result = selector.performSelection(targetSpend); @@ -39,7 +38,7 @@ describe('FewestCoinSelector', function () { it('selects multiple UTXOs whose combined value meets the target amount', function () { const availableUTXOs = createUTXOs([1, 2, 2, 3]); // .075 Qi - const targetSpend = { value: denominations[2] + denominations[3], address: TEST_RECEIVE_ADDRESS }; // .06 Qi + const targetSpend = denominations[2] + denominations[3]; // .06 Qi const selector = new FewestCoinSelector(availableUTXOs); const result = selector.performSelection(targetSpend); @@ -62,7 +61,7 @@ describe('FewestCoinSelector', function () { it('selects a single UTXO that is larger than the target amount, ensuring change is correctly calculated', function () { const availableUTXOs = createUTXOs([2, 4]); // .11 Qi - const targetSpend = { value: denominations[3], address: TEST_RECEIVE_ADDRESS }; // .05 Qi + const targetSpend = denominations[3]; // .05 Qi const selector = new FewestCoinSelector(availableUTXOs); const result = selector.performSelection(targetSpend); @@ -81,7 +80,7 @@ describe('FewestCoinSelector', function () { it('selects multiple UTXOs where the total exceeds the target amount, ensuring change is correctly calculated', function () { const availableUTXOs = createUTXOs([2, 4, 4, 4, 5]); // .56 Qi - const targetSpend = { value: denominations[6], address: TEST_RECEIVE_ADDRESS }; // .5 Qi + const targetSpend = denominations[6]; // .5 Qi const selector = new FewestCoinSelector(availableUTXOs); const result = selector.performSelection(targetSpend); @@ -94,9 +93,10 @@ describe('FewestCoinSelector', function () { denominations[result.inputs[3].denomination!]; assert.strictEqual(inputValue, denominations[4] + denominations[4] + denominations[4] + denominations[5]); - // A single new 0.5 Qi UTXO should have been outputed - assert.strictEqual(result.spendOutputs.length, 1); - assert.strictEqual(result.spendOutputs[0].denomination, 6); + // Two 0.25 Qi UTXOs should have been outputed + assert.strictEqual(result.spendOutputs.length, 2); + assert.strictEqual(result.spendOutputs[0].denomination, 5); + assert.strictEqual(result.spendOutputs[1].denomination, 5); // 0.05 Qi should be returned in change assert.strictEqual(result.changeOutputs.length, 1); @@ -107,26 +107,243 @@ describe('FewestCoinSelector', function () { describe('Error cases', function () { it('throws an error when there are insufficient funds', function () { const selector = new FewestCoinSelector(createUTXOs([0, 0])); - assert.throws( - () => selector.performSelection({ value: denominations[3], address: TEST_RECEIVE_ADDRESS }), - /Insufficient funds/, - ); + assert.throws(() => selector.performSelection(denominations[3]), /Insufficient funds/); }); it('throws an error when no UTXOs are available', function () { const selector = new FewestCoinSelector([]); - assert.throws( - () => selector.performSelection({ value: denominations[2], address: TEST_RECEIVE_ADDRESS }), - /No UTXOs available/, - ); + assert.throws(() => selector.performSelection(denominations[2]), /No UTXOs available/); }); it('throws an error when the target amount is negative', function () { const selector = new FewestCoinSelector(createUTXOs([2, 2])); - assert.throws( - () => selector.performSelection({ value: -denominations[1], address: TEST_RECEIVE_ADDRESS }), - /Target amount must be greater than 0/, - ); + assert.throws(() => selector.performSelection(-denominations[1]), /Target amount must be greater than 0/); + }); + }); + + // New tests for increaseFee and decreaseFee + describe('Fee Adjustment Methods', function () { + it('increases fee by reducing change outputs when sufficient change is available', function () { + const availableUTXOs = createUTXOs([3]); // Denomination index 3 (50 units) + const targetSpend = denominations[2]; // 10 units + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // Calculate expected initial change amount + const initialChangeAmount = denominations[3] - denominations[2]; // 50 - 10 = 40 units + + const maxInputDenomination = denominations[3]; // 50 units + + // Denominate the change amount using maxDenomination + const expectedChangeDenominations = denominate(initialChangeAmount, maxInputDenomination); + + // Assert that change outputs are correctly created + assert.strictEqual(selector.changeOutputs.length, expectedChangeDenominations.length); + + // Verify that the change outputs sum to the expected change amount + const actualInitialChangeAmount = selector.changeOutputs.reduce((sum, output) => { + return sum + denominations[output.denomination!]; + }, BigInt(0)); + assert.strictEqual(actualInitialChangeAmount, initialChangeAmount); + + // Increase fee by 10 units + const additionalFeeNeeded = denominations[2]; // 10 units + const success = selector.increaseFee(additionalFeeNeeded); + + assert.strictEqual(success, true); + + // Calculate expected new change amount + const newChangeAmount = initialChangeAmount - additionalFeeNeeded; // 40 - 10 = 30 units + + // Denominate the new change amount + const expectedNewChangeDenominations = denominate(newChangeAmount, maxInputDenomination); + + // Assert that change outputs are updated correctly + assert.strictEqual(selector.changeOutputs.length, expectedNewChangeDenominations.length); + + // Verify that the change outputs sum to the new change amount + const actualNewChangeAmount = selector.changeOutputs.reduce((sum, output) => { + return sum + denominations[output.denomination!]; + }, BigInt(0)); + assert.strictEqual(actualNewChangeAmount, newChangeAmount); + + // Ensure total input value remains the same + assert.strictEqual(selector.totalInputValue, denominations[3]); + + // Ensure no additional inputs were added + assert.strictEqual(selector.selectedUTXOs.length, 1); + }); + + it('increases fee by adding inputs when change outputs are insufficient', function () { + const availableUTXOs = createUTXOs([2, 2, 2]); // Denomination index 2 (10 units each) + const targetSpend = denominations[2] * BigInt(2); // 20 units + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // Initially, no change outputs (total input = 20 units) + assert.strictEqual(selector.changeOutputs.length, 0); + + // Increase fee by 10 units + const additionalFeeNeeded = denominations[2]; // 10 units + const success = selector.increaseFee(additionalFeeNeeded); + + assert.strictEqual(success, true); + + // After adding an additional input, total input value is 30 units + assert.strictEqual(selector.totalInputValue, denominations[2] * BigInt(3)); // 30 units + + // Calculate expected change amount + // const expectedChangeAmount = selector.totalInputValue - targetSpend.value - additionalFeeNeeded; // 30 - 20 - 10 = 0 units + + // Since change amount is zero, no change outputs + assert.strictEqual(selector.changeOutputs.length, 0); + + // Verify that the number of selected UTXOs is now 3 + assert.strictEqual(selector.selectedUTXOs.length, 3); + }); + + it('fails to increase fee when no additional inputs are available', function () { + const availableUTXOs = createUTXOs([2, 2]); // Two .01 Qi UTXOs + const targetSpend = denominations[2] * BigInt(2); // .02 Qi + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // No change outputs expected + assert.strictEqual(selector.changeOutputs.length, 0); + + // Attempt to increase fee by .01 Qi + const additionalFeeNeeded = denominations[2]; // .01 Qi + const success = selector.increaseFee(additionalFeeNeeded); + + // Should fail due to insufficient funds + assert.strictEqual(success, false); + + // Inputs and outputs remain unchanged + assert.strictEqual(selector.selectedUTXOs.length, 2); + assert.strictEqual(selector.changeOutputs.length, 0); + }); + + it('decreases fee by increasing change outputs when possible', function () { + const availableUTXOs = createUTXOs([3, 2]); // .05 Qi and .01 Qi + const targetSpend = denominations[3]; // .05 Qi + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // No change outputs expected + assert.strictEqual(selector.changeOutputs.length, 0); + + // Decrease fee by .01 Qi + const feeReduction = denominations[2]; // .01 Qi + selector.decreaseFee(feeReduction); + + // Change output should now reflect the reduced fee + assert.strictEqual(selector.changeOutputs.length, 1); + assert.strictEqual(denominations[selector.changeOutputs[0].denomination!], denominations[2]); + + // Inputs remain the same + assert.strictEqual(selector.selectedUTXOs.length, 1); + }); + + it.only('decreases fee by removing inputs when possible', function () { + const availableUTXOs = createUTXOs([3, 2]); // Denomination indices 3 (50 units) and 2 (10 units) + const targetSpend = denominations[1]; // 20 units + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // Initially, selects the 50-unit UTXO for the target spend + assert.strictEqual(selector.selectedUTXOs.length, 1); + assert.strictEqual(selector.totalInputValue, denominations[2]); // 10 units + + // Calculate initial change amount + const initialChangeAmount = denominations[2] - denominations[1]; // 10 - 5 = 5 units + + // Decrease fee by 5 units + const feeReduction = denominations[1]; // 5 units + selector.decreaseFee(feeReduction); + + // New change amount should include the fee reduction + const newChangeAmount = initialChangeAmount - feeReduction; // 5 + 5 = 10 units + + // Denominate new change amount using max input denomination (50 units) + const expectedChangeDenominations = denominate(newChangeAmount, denominations[2]); + + // Assert that change outputs are updated correctly + assert.strictEqual(selector.changeOutputs.length, expectedChangeDenominations.length); + + // Verify that the change outputs sum to the new change amount + const actualNewChangeAmount = selector.changeOutputs.reduce((sum, output) => { + return sum + denominations[output.denomination!]; + }, BigInt(0)); + assert.strictEqual(actualNewChangeAmount, newChangeAmount); + + // Inputs remain the same (cannot remove inputs without violating protocol rules) + assert.strictEqual(selector.selectedUTXOs.length, 1); + assert.strictEqual(selector.totalInputValue, denominations[2]); // Still 10 units + }); + + it('does not remove inputs if it would result in insufficient funds when decreasing fee', function () { + const availableUTXOs = createUTXOs([3]); // .05 Qi + const targetSpend = denominations[3]; // .05 Qi + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // No change outputs expected + assert.strictEqual(selector.changeOutputs.length, 0); + + // Decrease fee by .01 Qi + const feeReduction = denominations[2]; // .01 Qi + selector.decreaseFee(feeReduction); + + // Cannot remove any inputs, but can adjust change outputs + // Change output should now reflect the reduced fee + assert.strictEqual(selector.changeOutputs.length, 1); + assert.strictEqual(denominations[selector.changeOutputs[0].denomination!], denominations[2]); + + // Inputs remain the same + assert.strictEqual(selector.selectedUTXOs.length, 1); + assert.strictEqual(selector.totalInputValue, denominations[3]); + }); + + it('handles edge case where fee increase consumes entire change output and requires additional inputs', function () { + const availableUTXOs = createUTXOs([2, 2]); // Denomination indices 2 (10 units each) + const targetSpend = denominations[2]; // 10 units + const selector = new FewestCoinSelector(availableUTXOs); + selector.performSelection(targetSpend); + + // Initially, selects one UTXO, change expected + assert.strictEqual(selector.selectedUTXOs.length, 1); + assert.strictEqual(selector.totalInputValue, denominations[2]); // 10 units + + // Calculate initial change amount + // const initialChangeAmount = denominations[2] - denominations[2]; // 10 - 10 = 0 units + + // No change outputs expected + assert.strictEqual(selector.changeOutputs.length, 0); + + // Increase fee by 5 units + const additionalFeeNeeded = denominations[1]; // 5 units + const success = selector.increaseFee(additionalFeeNeeded); + + assert.strictEqual(success, true); + + // Now, an additional input is added + assert.strictEqual(selector.selectedUTXOs.length, 2); + assert.strictEqual(selector.totalInputValue, denominations[2] * BigInt(2)); // 20 units + + // New change amount + const newChangeAmount = selector.totalInputValue - targetSpend - additionalFeeNeeded; // 20 - 10 - 5 = 5 units + + // Denominate the new change amount using max input denomination (10 units) + const expectedChangeDenominations = denominate(newChangeAmount, denominations[2]); + + // Assert that change outputs are correctly created + assert.strictEqual(selector.changeOutputs.length, expectedChangeDenominations.length); + + // Verify that the change outputs sum to the new change amount + const actualChangeAmount = selector.changeOutputs.reduce((sum, output) => { + return sum + denominations[output.denomination!]; + }, BigInt(0)); + assert.strictEqual(actualChangeAmount, newChangeAmount); }); }); }); diff --git a/src/encoding/protoc/proto_block.proto b/src/encoding/protoc/proto_block.proto index 88004e6a..fdc7d36a 100644 --- a/src/encoding/protoc/proto_block.proto +++ b/src/encoding/protoc/proto_block.proto @@ -167,4 +167,5 @@ message ProtoOutPoint { message ProtoTxOut { optional uint32 denomination = 1; optional bytes address = 2; + optional bytes lock = 3; } diff --git a/src/encoding/protoc/proto_block.ts b/src/encoding/protoc/proto_block.ts index 4f9985cb..5d9dfbc8 100644 --- a/src/encoding/protoc/proto_block.ts +++ b/src/encoding/protoc/proto_block.ts @@ -1,6 +1,6 @@ /** * Generated by the protoc-gen-ts. DO NOT EDIT! - * compiler version: 4.25.3 + * compiler version: 4.24.3 * source: proto_block.proto * git: https://github.com/thesayyn/protoc-gen-ts */ import * as dependency_1 from "./proto_common.js"; @@ -4394,11 +4394,13 @@ export namespace block { } } export class ProtoTxOut extends pb_1.Message { - #one_of_decls: number[][] = [[1], [2]]; + #one_of_decls: number[][] = [[1], [2], [3]]; constructor(data?: any[] | ({} & (({ denomination?: number; }) | ({ address?: Uint8Array; + }) | ({ + lock?: Uint8Array; })))) { super(); pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls); @@ -4409,6 +4411,9 @@ export namespace block { if ("address" in data && data.address != undefined) { this.address = data.address; } + if ("lock" in data && data.lock != undefined) { + this.lock = data.lock; + } } } get denomination() { @@ -4429,6 +4434,15 @@ export namespace block { get has_address() { return pb_1.Message.getField(this, 2) != null; } + get lock() { + return pb_1.Message.getFieldWithDefault(this, 3, new Uint8Array(0)) as Uint8Array; + } + set lock(value: Uint8Array) { + pb_1.Message.setOneofField(this, 3, this.#one_of_decls[2], value); + } + get has_lock() { + return pb_1.Message.getField(this, 3) != null; + } get _denomination() { const cases: { [index: number]: "none" | "denomination"; @@ -4447,9 +4461,19 @@ export namespace block { }; return cases[pb_1.Message.computeOneofCase(this, [2])]; } + get _lock() { + const cases: { + [index: number]: "none" | "lock"; + } = { + 0: "none", + 3: "lock" + }; + return cases[pb_1.Message.computeOneofCase(this, [3])]; + } static fromObject(data: { denomination?: number; address?: Uint8Array; + lock?: Uint8Array; }): ProtoTxOut { const message = new ProtoTxOut({}); if (data.denomination != null) { @@ -4458,12 +4482,16 @@ export namespace block { if (data.address != null) { message.address = data.address; } + if (data.lock != null) { + message.lock = data.lock; + } return message; } toObject() { const data: { denomination?: number; address?: Uint8Array; + lock?: Uint8Array; } = {}; if (this.denomination != null) { data.denomination = this.denomination; @@ -4471,6 +4499,9 @@ export namespace block { if (this.address != null) { data.address = this.address; } + if (this.lock != null) { + data.lock = this.lock; + } return data; } serialize(): Uint8Array; @@ -4481,6 +4512,8 @@ export namespace block { writer.writeUint32(1, this.denomination); if (this.has_address) writer.writeBytes(2, this.address); + if (this.has_lock) + writer.writeBytes(3, this.lock); if (!w) return writer.getResultBuffer(); } @@ -4496,6 +4529,9 @@ export namespace block { case 2: message.address = reader.readBytes(); break; + case 3: + message.lock = reader.readBytes(); + break; default: reader.skipField(); } } diff --git a/src/encoding/protoc/proto_common.ts b/src/encoding/protoc/proto_common.ts index fcc3d3af..884127fb 100644 --- a/src/encoding/protoc/proto_common.ts +++ b/src/encoding/protoc/proto_common.ts @@ -1,6 +1,6 @@ /** * Generated by the protoc-gen-ts. DO NOT EDIT! - * compiler version: 4.25.3 + * compiler version: 4.24.3 * source: proto_common.proto * git: https://github.com/thesayyn/protoc-gen-ts */ import * as pb_1 from "google-protobuf"; diff --git a/src/providers/abstract-provider.ts b/src/providers/abstract-provider.ts index 8b8d4ecd..679c2099 100644 --- a/src/providers/abstract-provider.ts +++ b/src/providers/abstract-provider.ts @@ -55,14 +55,7 @@ import type { BigNumberish } from '../utils/index.js'; import type { Listener } from '../utils/index.js'; import type { Networkish } from './network.js'; -import type { - BlockParams, - LogParams, - OutpointResponseParams, - QiTransactionResponseParams, - TransactionReceiptParams, - TransactionResponseParams, -} from './formatting.js'; +import type { BlockParams, LogParams, OutpointResponseParams, TransactionReceiptParams } from './formatting.js'; import type { BlockTag, @@ -1020,11 +1013,21 @@ export class AbstractProvider implements Provider { */ // TODO: `newtork` is not used, remove or re-write // eslint-disable-next-line @typescript-eslint/no-unused-vars - _wrapTransactionResponse(tx: TransactionResponseParams, network: Network): TransactionResponse { - if ('from' in tx) { - return new QuaiTransactionResponse(formatTransactionResponse(tx) as QuaiTransactionResponseParams, this); - } else { - return new QiTransactionResponse(formatTransactionResponse(tx) as QiTransactionResponseParams, this); + _wrapTransactionResponse(tx: any, network: Network): TransactionResponse { + try { + if (tx.type === 0 || tx.type === 1) { + // For QuaiTransaction, format and wrap as before + const formattedTx = formatTransactionResponse(tx) as QuaiTransactionResponseParams; + return new QuaiTransactionResponse(formattedTx, this); + } else if (tx.type === 2) { + // For QiTransaction, use fromProto() directly + return new QiTransactionResponse(tx, this); + } else { + throw new Error('Unknown transaction type'); + } + } catch (error) { + console.error('Error in _wrapTransactionResponse:', error); + throw error; } } @@ -1529,25 +1532,33 @@ export class AbstractProvider implements Provider { // Write async broadcastTransaction(zone: Zone, signedTx: string): Promise { const type = decodeProtoTransaction(getBytes(signedTx)).type; - const { blockNumber, hash, network } = await resolveProperties({ - blockNumber: this.getBlockNumber(toShard(zone)), - hash: this._perform({ - method: 'broadcastTransaction', - signedTransaction: signedTx, - zone: zone, - }), - network: this.getNetwork(), - }); + try { + const { blockNumber, hash, network } = await resolveProperties({ + blockNumber: this.getBlockNumber(toShard(zone)), + hash: this._perform({ + method: 'broadcastTransaction', + signedTransaction: signedTx, + zone: zone, + }), + network: this.getNetwork(), + }); - const tx = type == 2 ? QiTransaction.from(signedTx) : QuaiTransaction.from(signedTx); + const tx = type == 2 ? QiTransaction.from(signedTx) : QuaiTransaction.from(signedTx); + const txObj = tx.toJSON(); - this.#validateTransactionHash(tx.hash || '', hash); - return this._wrapTransactionResponse(tx, network).replaceableTransaction(blockNumber); + this.#validateTransactionHash(tx.hash || '', hash); + + const wrappedTx = this._wrapTransactionResponse(txObj, network); + return wrappedTx.replaceableTransaction(blockNumber); + } catch (error) { + console.error('Error in broadcastTransaction:', error); + throw error; + } } #validateTransactionHash(computedHash: string, nodehash: string) { if (computedHash !== nodehash) { - throw new Error('Transaction hash mismatch'); + throw new Error(`Transaction hash mismatch: ${computedHash} !== ${nodehash}`); } } diff --git a/src/providers/provider-jsonrpc.ts b/src/providers/provider-jsonrpc.ts index 0dadae95..fd3ae0c5 100644 --- a/src/providers/provider-jsonrpc.ts +++ b/src/providers/provider-jsonrpc.ts @@ -884,7 +884,7 @@ export abstract class JsonRpcApiProvider extends AbstractProvi if (tx && tx.type != null && getBigInt(tx.type)) { // If there are no EIP-1559 properties, it might be non-EIP-a559 if (tx.maxFeePerGas == null && tx.maxPriorityFeePerGas == null) { - const feeData = await this.getFeeData(req.zone); + const feeData = await this.getFeeData(req.zone, tx.type === 1); // tx type 1 is Quai and 2 is Qi if (feeData.maxFeePerGas == null && feeData.maxPriorityFeePerGas == null) { // Network doesn't know about EIP-1559 (and hence type) req = Object.assign({}, req, { @@ -1119,7 +1119,6 @@ export abstract class JsonRpcApiProvider extends AbstractProvi (result)[dstKey] = toQuantity(getBigInt((tx)[key], `tx.${key}`)); }); - // Make sure addresses and data are lowercase ['from', 'to', 'data'].forEach((key) => { if ((tx)[key] == null) { return; @@ -1132,8 +1131,22 @@ export abstract class JsonRpcApiProvider extends AbstractProvi (result as QuaiJsonRpcTransactionRequest)['accessList'] = accessListify(tx.accessList); } } else { - throw new Error('No Qi getRPCTransaction implementation yet'); + if ((tx).txInputs != null) { + (result as QiJsonRpcTransactionRequest)['txInputs'] = (tx).txInputs.map((input: TxInput) => ({ + txhash: hexlify(input.txhash), + index: toQuantity(getBigInt(input.index, `tx.txInputs.${input.index}`)), + pubkey: hexlify(input.pubkey), + })); + } + + if ((tx).txOutputs != null) { + (result as QiJsonRpcTransactionRequest)['txOutputs'] = (tx).txOutputs.map((output: TxOutput) => ({ + address: hexlify(output.address), + denomination: toQuantity(getBigInt(output.denomination, `tx.txOutputs.${output.denomination}`)), + })); + } } + return result; } diff --git a/src/providers/provider.ts b/src/providers/provider.ts index 3a4138a0..6b622a82 100644 --- a/src/providers/provider.ts +++ b/src/providers/provider.ts @@ -2743,9 +2743,10 @@ export interface Provider extends ContractRunner, EventEmitterable} A promise resolving to the fee data. */ - getFeeData(zone: Zone): Promise; + getFeeData(zone: Zone, txType: boolean): Promise; /** * Get a work object to package a transaction in. diff --git a/src/quais.ts b/src/quais.ts index f1f84b9e..ae59ebe2 100644 --- a/src/quais.ts +++ b/src/quais.ts @@ -214,6 +214,8 @@ export { decryptKeystoreJson, encryptKeystoreJson, encryptKeystoreJsonSync, + SerializedHDWallet, + SerializedQiHDWallet, } from './wallet/index.js'; // WORDLIST diff --git a/src/signers/abstract-signer.ts b/src/signers/abstract-signer.ts index 542cb90c..f45111d8 100644 --- a/src/signers/abstract-signer.ts +++ b/src/signers/abstract-signer.ts @@ -113,10 +113,6 @@ export abstract class AbstractSigner

{ - const utxo = UTXO.from(val); - this._validateUTXO(utxo); - return utxo; - }); - } - - /** - * Gets the spend outputs. - * - * @returns {UTXO[]} The spend outputs. - */ - get spendOutputs(): UTXO[] { - return this.#spendOutputs; - } - - /** - * Sets the spend outputs. - * - * @param {UTXOLike[]} value - The spend outputs to set. - */ - set spendOutputs(value: UTXOLike[]) { - this.#spendOutputs = value.map((utxo) => UTXO.from(utxo)); - } - - /** - * Gets the change outputs. - * - * @returns {UTXO[]} The change outputs. - */ - get changeOutputs(): UTXO[] { - return this.#changeOutputs; - } - - /** - * Sets the change outputs. - * - * @param {UTXOLike[]} value - The change outputs to set. - */ - set changeOutputs(value: UTXOLike[]) { - this.#changeOutputs = value.map((utxo) => UTXO.from(utxo)); - } + public availableUTXOs: UTXO[]; + public totalInputValue: bigint = BigInt(0); + public spendOutputs: UTXO[] = []; + public changeOutputs: UTXO[] = []; + public selectedUTXOs: UTXO[] = []; + public target: bigint | null = null; /** * Constructs a new AbstractCoinSelector instance with an empty UTXO array. * - * @param {UTXOEntry[]} [availableUXTOs=[]] - The initial available UTXOs. Default is `[]` + * @param {UTXO[]} [availableUXTOs=[]] - The initial available UTXOs. Default is `[]` */ constructor(availableUTXOs: UTXO[] = []) { - this.#availableUTXOs = availableUTXOs.map((utxo: UTXO) => { + this.availableUTXOs = availableUTXOs.map((utxo: UTXO) => { this._validateUTXO(utxo); return utxo; }); - this.#spendOutputs = []; - this.#changeOutputs = []; + this.spendOutputs = []; + this.changeOutputs = []; } /** @@ -123,7 +68,7 @@ export abstract class AbstractCoinSelector { * @param {SpendTarget} target - The target address and value to spend. * @returns {SelectedCoinsResult} The selected UTXOs and outputs. */ - abstract performSelection(target: SpendTarget): SelectedCoinsResult; + abstract performSelection(target: bigint): SelectedCoinsResult; /** * Validates the provided UTXO instance. In order to be valid for coin selection, the UTXO must have a valid address diff --git a/src/transaction/abstract-transaction.ts b/src/transaction/abstract-transaction.ts index 9ad6cf79..c017274a 100644 --- a/src/transaction/abstract-transaction.ts +++ b/src/transaction/abstract-transaction.ts @@ -150,6 +150,11 @@ export type ProtoTxOutput = { * The denomination of the output. */ denomination: number; + + /** + * The lock of the output. + */ + lock?: Uint8Array; }; /** diff --git a/src/transaction/coinselector-fewest.ts b/src/transaction/coinselector-fewest.ts index 19f3201d..b8b87463 100644 --- a/src/transaction/coinselector-fewest.ts +++ b/src/transaction/coinselector-fewest.ts @@ -1,5 +1,5 @@ import { bigIntAbs } from '../utils/maths.js'; -import { AbstractCoinSelector, SelectedCoinsResult, SpendTarget } from './abstract-coinselector.js'; +import { AbstractCoinSelector, SelectedCoinsResult } from './abstract-coinselector.js'; import { UTXO, denominate, denominations } from './utxo.js'; /** @@ -14,18 +14,22 @@ import { UTXO, denominate, denominations } from './utxo.js'; */ export class FewestCoinSelector extends AbstractCoinSelector { /** - * The largest first coin selection algorithm. + * The coin selection algorithm considering transaction fees. * - * This algorithm selects the largest UTXOs first, and continues to select UTXOs until the target amount is reached. - * If the total value of the selected UTXOs is greater than the target amount, the remaining value is returned as a - * change output. - * - * @param {SpendTarget} target - The target amount to spend. + * @param {bigint} target - The target amount to spend. * @returns {SelectedCoinsResult} The selected UTXOs and change outputs. */ - performSelection(target: SpendTarget): SelectedCoinsResult { - this.validateTarget(target); + performSelection(target: bigint): SelectedCoinsResult { + if (target <= BigInt(0)) { + throw new Error('Target amount must be greater than 0'); + } + this.validateUTXOs(); + this.target = target; + + // Initialize selection state + this.selectedUTXOs = []; + this.totalInputValue = BigInt(0); const sortedUTXOs = this.sortUTXOsByDenomination(this.availableUTXOs, 'desc'); @@ -34,40 +38,45 @@ export class FewestCoinSelector extends AbstractCoinSelector { // Get UTXOs that meets or exceeds the target value const UTXOsEqualOrGreaterThanTarget = sortedUTXOs.filter( - (utxo) => utxo.denomination !== null && denominations[utxo.denomination] >= target.value, + (utxo) => utxo.denomination !== null && BigInt(denominations[utxo.denomination]) >= target, ); if (UTXOsEqualOrGreaterThanTarget.length > 0) { // Find the smallest UTXO that meets or exceeds the target value const optimalUTXO = UTXOsEqualOrGreaterThanTarget.reduce((minDenominationUTXO, currentUTXO) => { if (currentUTXO.denomination === null) return minDenominationUTXO; - return denominations[currentUTXO.denomination] < denominations[minDenominationUTXO.denomination!] + return BigInt(denominations[currentUTXO.denomination]) < + BigInt(denominations[minDenominationUTXO.denomination!]) ? currentUTXO : minDenominationUTXO; }, UTXOsEqualOrGreaterThanTarget[0]); selectedUTXOs.push(optimalUTXO); - totalValue += denominations[optimalUTXO.denomination!]; + totalValue += BigInt(denominations[optimalUTXO.denomination!]); } else { // If no single UTXO meets or exceeds the target, aggregate smaller denominations // until the target is met/exceeded or there are no more UTXOs to aggregate - while (sortedUTXOs.length > 0 && totalValue < target.value) { + while (sortedUTXOs.length > 0 && totalValue < target) { const nextOptimalUTXO = sortedUTXOs.reduce((closest, utxo) => { if (utxo.denomination === null) return closest; // Prioritize UTXOs that bring totalValue closer to target.value - const absThisDiff = bigIntAbs(target.value - (totalValue + denominations[utxo.denomination])); + const absThisDiff = bigIntAbs( + BigInt(target) - (BigInt(totalValue) + BigInt(denominations[utxo.denomination])), + ); const currentClosestDiff = closest && closest.denomination !== null - ? bigIntAbs(target.value - (totalValue + denominations[closest.denomination])) - : BigInt(Infinity); + ? bigIntAbs( + BigInt(target) - (BigInt(totalValue) + BigInt(denominations[closest.denomination])), + ) + : BigInt(Number.MAX_SAFE_INTEGER); return absThisDiff < currentClosestDiff ? utxo : closest; }, sortedUTXOs[0]); // Add the selected UTXO to the selection and update totalValue selectedUTXOs.push(nextOptimalUTXO); - totalValue += denominations[nextOptimalUTXO.denomination!]; + totalValue += BigInt(denominations[nextOptimalUTXO.denomination!]); // Remove the selected UTXO from the list of available UTXOs const index = sortedUTXOs.findIndex( @@ -78,71 +87,195 @@ export class FewestCoinSelector extends AbstractCoinSelector { } } - // Replace the existing optimization code with this new implementation - selectedUTXOs = this.sortUTXOsByDenomination(selectedUTXOs, 'desc'); - let runningTotal = totalValue; - - for (let i = selectedUTXOs.length - 1; i >= 0; i--) { - const utxo = selectedUTXOs[i]; - if (utxo.denomination !== null && runningTotal - denominations[utxo.denomination] >= target.value) { - runningTotal -= denominations[utxo.denomination]; - selectedUTXOs.splice(i, 1); - } else { - break; + // Optimize the selection process + let optimalSelection = selectedUTXOs; + let minExcess = BigInt(totalValue) - BigInt(target); + + for (let i = 0; i < selectedUTXOs.length; i++) { + const subsetUTXOs = selectedUTXOs.slice(0, i).concat(selectedUTXOs.slice(i + 1)); + const subsetTotal = subsetUTXOs.reduce( + (sum, utxo) => BigInt(sum) + BigInt(denominations[utxo.denomination!]), + BigInt(0), + ); + + if (subsetTotal >= target) { + const excess = BigInt(subsetTotal) - BigInt(target); + if (excess < minExcess) { + optimalSelection = subsetUTXOs; + minExcess = excess; + totalValue = subsetTotal; + } } } - totalValue = runningTotal; + selectedUTXOs = optimalSelection; - // Ensure that selectedUTXOs contain all required properties - const completeSelectedUTXOs = selectedUTXOs.map((utxo) => { - const originalUTXO = this.availableUTXOs.find( - (availableUTXO) => - availableUTXO.denomination === utxo.denomination && availableUTXO.address === utxo.address, - ); - if (!originalUTXO) { - throw new Error('Selected UTXO not found in available UTXOs'); - } - return originalUTXO; - }); + // Find the largest denomination used in the inputs + + // Store the selected UTXOs and total input value + this.selectedUTXOs = selectedUTXOs; + this.totalInputValue = totalValue; // Check if the selected UTXOs meet or exceed the target amount - if (totalValue < target.value) { + if (totalValue < target) { throw new Error('Insufficient funds'); } - // Break down the total spend into properly denominatated UTXOs - const spendDenominations = denominate(target.value); - this.spendOutputs = spendDenominations.map((denomination) => { + // Store spendOutputs and changeOutputs + this.spendOutputs = this.createSpendOutputs(target); + this.changeOutputs = this.createChangeOutputs(BigInt(totalValue) - BigInt(target)); + + return { + inputs: selectedUTXOs, + spendOutputs: this.spendOutputs, + changeOutputs: this.changeOutputs, + }; + } + + // Helper methods to create spend and change outputs + private createSpendOutputs(amount: bigint): UTXO[] { + const maxDenomination = this.getMaxInputDenomination(); + + const spendDenominations = denominate(amount, maxDenomination); + return spendDenominations.map((denomination) => { + const utxo = new UTXO(); + utxo.denomination = denominations.indexOf(denomination); + return utxo; + }); + } + + private createChangeOutputs(change: bigint): UTXO[] { + if (change <= BigInt(0)) { + return []; + } + + const maxDenomination = this.getMaxInputDenomination(); + + const changeDenominations = denominate(change, maxDenomination); + return changeDenominations.map((denomination) => { const utxo = new UTXO(); utxo.denomination = denominations.indexOf(denomination); - utxo.address = target.address; return utxo; }); + } - // Calculate change to be returned - const change = totalValue - target.value; - - // If there's change, break it down into properly denominatated UTXOs - if (change > BigInt(0)) { - const changeDenominations = denominate(change); - this.changeOutputs = changeDenominations.map((denomination) => { - const utxo = new UTXO(); - utxo.denomination = denominations.indexOf(denomination); - // We do not have access to change addresses here so leave it null - return utxo; - }); - } else { - this.changeOutputs = []; + /** + * Increases the total fee by first reducing change outputs, then selecting additional inputs if necessary. + * + * @param {bigint} additionalFeeNeeded - The additional fee needed. + * @returns {boolean} Returns true if successful, false if insufficient funds. + */ + increaseFee(additionalFeeNeeded: bigint): SelectedCoinsResult { + let remainingFee = BigInt(additionalFeeNeeded); + + // First, try to cover the fee by reducing change outputs + const totalChange = this.changeOutputs.reduce( + (sum, output) => BigInt(sum) + BigInt(denominations[output.denomination!]), + BigInt(0), + ); + + if (totalChange >= remainingFee) { + // We can cover the fee by reducing change outputs + this.adjustChangeOutputs(totalChange - remainingFee); + return { + inputs: this.selectedUTXOs, + spendOutputs: this.spendOutputs, + changeOutputs: this.changeOutputs, + }; + } + + // If we can't cover the entire fee with change, reduce change to zero and calculate remaining fee + remainingFee -= BigInt(totalChange); + this.changeOutputs = []; + + // Now, select additional inputs to cover the remaining fee + const unusedUTXOs = this.availableUTXOs.filter((utxo) => !this.selectedUTXOs.includes(utxo)); + const sortedUTXOs = this.sortUTXOsByDenomination(unusedUTXOs, 'asc'); + + for (const utxo of sortedUTXOs) { + this.selectedUTXOs.push(utxo); + this.totalInputValue += BigInt(denominations[utxo.denomination!]); + remainingFee -= BigInt(denominations[utxo.denomination!]); + + if (remainingFee <= BigInt(0)) { + // If we have excess, create a new change output + if (remainingFee < BigInt(0)) { + const change = + BigInt(this.totalInputValue) - + BigInt(this.target!) - + (BigInt(additionalFeeNeeded) - BigInt(remainingFee)); + this.adjustChangeOutputs(change); + } + } + } + + return { + inputs: this.selectedUTXOs, + spendOutputs: this.spendOutputs, + changeOutputs: this.changeOutputs, + }; + } + + /** + * Decreases the fee by removing inputs if possible and adjusting change outputs. + * + * @param {bigint} feeReduction - The amount by which the fee has decreased. + * @returns {void} + */ + decreaseFee(feeReduction: bigint): SelectedCoinsResult { + let excessValue = feeReduction; + + // First, try to remove inputs + const sortedInputs = this.sortUTXOsByDenomination(this.selectedUTXOs, 'desc'); + const inputsToRemove: UTXO[] = []; + + for (const input of sortedInputs) { + const inputValue = BigInt(denominations[input.denomination!]); + if (excessValue >= inputValue && this.totalInputValue - inputValue >= this.target!) { + inputsToRemove.push(input); + excessValue -= BigInt(inputValue); + this.totalInputValue -= BigInt(inputValue); + } + + if (excessValue === BigInt(0)) break; + } + + // Remove the identified inputs + this.selectedUTXOs = this.selectedUTXOs.filter((utxo) => !inputsToRemove.includes(utxo)); + + // If there's still excess value, add it to change outputs + if (excessValue > BigInt(0)) { + this.adjustChangeOutputs(excessValue); } return { - inputs: completeSelectedUTXOs, + inputs: this.selectedUTXOs, spendOutputs: this.spendOutputs, changeOutputs: this.changeOutputs, }; } + private getMaxInputDenomination(): bigint { + return this.selectedUTXOs.reduce((max, utxo) => { + const denomValue = BigInt(denominations[utxo.denomination!]); + return denomValue > max ? denomValue : max; + }, BigInt(0)); + } + + /** + * Helper method to adjust change outputs. + * + * @param {bigint} changeAmount - The amount to adjust change outputs by. + */ + private adjustChangeOutputs(changeAmount: bigint): void { + if (changeAmount <= BigInt(0)) { + this.changeOutputs = []; + return; + } + + this.changeOutputs = this.createChangeOutputs(changeAmount); + } + /** * Sorts UTXOs by their denomination. * @@ -154,31 +287,19 @@ export class FewestCoinSelector extends AbstractCoinSelector { if (direction === 'asc') { return [...utxos].sort((a, b) => { const diff = - (a.denomination !== null ? denominations[a.denomination] : BigInt(0)) - - (b.denomination !== null ? denominations[b.denomination] : BigInt(0)); - return diff > 0 ? 1 : diff < 0 ? -1 : 0; + BigInt(a.denomination !== null ? denominations[a.denomination] : 0) - + BigInt(b.denomination !== null ? denominations[b.denomination] : 0); + return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0; }); } return [...utxos].sort((a, b) => { const diff = - (b.denomination !== null ? denominations[b.denomination] : BigInt(0)) - - (a.denomination !== null ? denominations[a.denomination] : BigInt(0)); - return diff > 0 ? 1 : diff < 0 ? -1 : 0; + BigInt(b.denomination !== null ? denominations[b.denomination] : 0) - + BigInt(a.denomination !== null ? denominations[a.denomination] : 0); + return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0; }); } - /** - * Validates the target amount. - * - * @param {SpendTarget} target - The target amount to validate. - * @throws Will throw an error if the target amount is less than or equal to 0. - */ - private validateTarget(target: SpendTarget) { - if (target.value <= BigInt(0)) { - throw new Error('Target amount must be greater than 0'); - } - } - /** * Validates the available UTXOs. * diff --git a/src/transaction/index.ts b/src/transaction/index.ts index c865888d..552018d3 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -29,6 +29,7 @@ export { accessListify } from './accesslist.js'; export { AbstractTransaction } from './abstract-transaction.js'; export { FewestCoinSelector } from './coinselector-fewest.js'; +export type { SpendTarget } from './abstract-coinselector.js'; export type { TransactionLike } from './abstract-transaction.js'; diff --git a/src/transaction/qi-transaction.ts b/src/transaction/qi-transaction.ts index 17a97688..ac7aa0f5 100644 --- a/src/transaction/qi-transaction.ts +++ b/src/transaction/qi-transaction.ts @@ -119,7 +119,9 @@ export class QiTransaction extends AbstractTransaction implements QiTran const hashHex = keccak256(dataBuffer); const hashBuffer = Buffer.from(hashHex.substring(2), 'hex'); - const origin = this.originZone ? parseInt(this.originZone.slice(2), 16) : 0; + const prevTxHash = this.txInputs[0].txhash; + const prevTxHashBytes = getBytes(prevTxHash); + const origin = prevTxHashBytes[1]; // Get the second byte (0-based index) hashBuffer[0] = origin; hashBuffer[1] |= 0x80; hashBuffer[2] = origin; @@ -234,6 +236,7 @@ export class QiTransaction extends AbstractTransaction implements QiTran tx_outs: this.txOutputs.map((output) => ({ address: getBytes(output.address), denomination: output.denomination, + lock: new Uint8Array(), })), }, }; @@ -241,7 +244,6 @@ export class QiTransaction extends AbstractTransaction implements QiTran if (this.signature && includeSignature) { protoTx.signature = getBytes(this.signature); } - return protoTx; } @@ -294,20 +296,18 @@ export class QiTransaction extends AbstractTransaction implements QiTran tx.type = protoTx.type; tx.chainId = toBigInt(protoTx.chain_id); - if (protoTx.type == 2) { - tx.txInputs = - protoTx.tx_ins?.tx_ins.map((input) => ({ - txhash: hexlify(input.previous_out_point.hash.value), - index: input.previous_out_point.index, - pubkey: hexlify(input.pub_key), - })) ?? []; - tx.txOutputs = - protoTx.tx_outs?.tx_outs.map((output) => ({ - address: hexlify(output.address), - denomination: output.denomination, - })) ?? []; - } - + tx.txInputs = + protoTx.tx_ins?.tx_ins.map((input) => ({ + txhash: hexlify(input.previous_out_point.hash.value), + index: input.previous_out_point.index, + pubkey: hexlify(input.pub_key), + })) ?? []; + tx.txOutputs = + protoTx.tx_outs?.tx_outs.map((output) => ({ + address: hexlify(output.address), + denomination: output.denomination, + lock: output.lock ? hexlify(output.lock) : '', + })) ?? []; if (protoTx.signature) { tx.signature = hexlify(protoTx.signature); } diff --git a/src/transaction/utxo.ts b/src/transaction/utxo.ts index 20f7990e..7413548d 100644 --- a/src/transaction/utxo.ts +++ b/src/transaction/utxo.ts @@ -53,6 +53,7 @@ export type TxInput = { export type TxOutput = { address: string; denomination: number; + lock?: string; }; /** @@ -61,23 +62,23 @@ export type TxOutput = { * @category Transaction */ export const denominations: bigint[] = [ - BigInt(1), // 0.001 Qi - BigInt(5), // 0.005 Qi - BigInt(10), // 0.01 Qi - BigInt(50), // 0.05 Qi - BigInt(100), // 0.1 Qi - BigInt(250), // 0.25 Qi - BigInt(500), // 0.5 Qi - BigInt(1000), // 1 Qi - BigInt(5000), // 5 Qi - BigInt(10000), // 10 Qi - BigInt(20000), // 20 Qi - BigInt(50000), // 50 Qi - BigInt(100000), // 100 Qi - BigInt(1000000), // 1000 Qi - BigInt(10000000), // 10000 Qi - BigInt(100000000), // 100000 Qi - BigInt(1000000000), // 1000000 Qi + BigInt(1), // 0.001 Qi (1 Qit) + BigInt(5), // 0.005 Qi (5 Qit) + BigInt(10), // 0.01 Qi (10 Qit) + BigInt(50), // 0.05 Qi (50 Qit) + BigInt(100), // 0.1 Qi (100 Qit) + BigInt(250), // 0.25 Qi (250 Qit) + BigInt(500), // 0.5 Qi (500 Qit) + BigInt(1000), // 1 Qi (1000 Qit) + BigInt(5000), // 5 Qi (5000 Qit) + BigInt(10000), // 10 Qi (10000 Qit) + BigInt(20000), // 20 Qi (20000 Qit) + BigInt(50000), // 50 Qi (50000 Qit) + BigInt(100000), // 100 Qi (100000 Qit) + BigInt(1000000), // 1,000 Qi (1,000,000 Qit) + BigInt(10000000), // 10,000 Qi (10,000,000 Qit) + BigInt(100000000), // 100,000 Qi (100,000,000 Qit) + BigInt(1000000000), // 1,000,000 Qi (1,000,000,000 Qit) ]; /** @@ -99,22 +100,34 @@ function isValidDenominationIndex(index: number): boolean { * @returns {bigint[]} An array of denominations that sum to the value. * @throws {Error} If the value is less than or equal to 0 or cannot be matched with available denominations. */ -export function denominate(value: bigint): bigint[] { +export function denominate(value: bigint, maxDenomination?: bigint): bigint[] { if (value <= BigInt(0)) { throw new Error('Value must be greater than 0'); } const result: bigint[] = []; - let remainingValue = value; + let remainingValue = BigInt(value); - // Iterate through denominations in descending order - for (let i = denominations.length - 1; i >= 0; i--) { + // Find the index of the maximum allowed denomination + let maxDenominationIndex: number; + if (maxDenomination != null) { + maxDenominationIndex = denominations.findIndex((d) => d === maxDenomination); + if (maxDenominationIndex === -1) { + throw new Error('Invalid maximum denomination'); + } + } else { + // No maximum denomination set, use the highest denomination + maxDenominationIndex = denominations.length - 1; + } + + // Iterate through denominations in descending order, up to the maximum allowed denomination + for (let i = maxDenominationIndex; i >= 0; i--) { const denomination = denominations[i]; // Add the denomination to the result array as many times as possible while (remainingValue >= denomination) { result.push(denomination); - remainingValue -= denomination; + remainingValue -= BigInt(denomination); } } diff --git a/src/wallet/hdwallet.ts b/src/wallet/hdwallet.ts index e127238f..796c660c 100644 --- a/src/wallet/hdwallet.ts +++ b/src/wallet/hdwallet.ts @@ -6,7 +6,7 @@ import { randomBytes } from '../crypto/index.js'; import { getZoneForAddress, assertPrivate } from '../utils/index.js'; import { isQiAddress } from '../address/index.js'; import { Zone } from '../constants/index.js'; -import { TransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; +import { TransactionRequest, Provider } from '../providers/index.js'; import { AllowedCoinType } from '../constants/index.js'; /** @@ -365,13 +365,13 @@ export abstract class AbstractHDWallet { */ abstract signTransaction(tx: TransactionRequest): Promise; - /** - * Abstract method to send a transaction. - * - * @param {TransactionRequest} tx - The transaction request. - * @returns {Promise} A promise that resolves to the transaction response. - */ - abstract sendTransaction(tx: TransactionRequest): Promise; + // /** + // * Abstract method to send a transaction. + // * + // * @param {TransactionRequest} tx - The transaction request. + // * @returns {Promise} A promise that resolves to the transaction response. + // */ + // abstract sendTransaction(tx: TransactionRequest): Promise; /** * Connects the wallet to a provider. diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 10df7f3a..85043b2d 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -13,6 +13,8 @@ export { BaseWallet } from './base-wallet.js'; +export type { SerializedHDWallet } from './hdwallet.js'; + export { QuaiHDWallet } from './quai-hdwallet.js'; export { @@ -29,6 +31,6 @@ export { Wallet } from './wallet.js'; export type { KeystoreAccount, EncryptOptions } from './json-keystore.js'; -export { QiHDWallet } from './qi-hdwallet.js'; +export { QiHDWallet, SerializedQiHDWallet } from './qi-hdwallet.js'; export { HDNodeVoidWallet, HDNodeWallet } from './hdnodewallet.js'; diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index 6397b5ac..ee6e8e6c 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -9,13 +9,11 @@ import { HDNodeWallet } from './hdnodewallet.js'; import { QiTransactionRequest, Provider, TransactionResponse } from '../providers/index.js'; import { computeAddress } from '../address/index.js'; import { getBytes, hexlify } from '../utils/index.js'; -import { TransactionLike, QiTransaction, TxInput } from '../transaction/index.js'; +import { TransactionLike, QiTransaction, TxInput, FewestCoinSelector } from '../transaction/index.js'; import { MuSigFactory } from '@brandonblack/musig'; import { schnorr } from '@noble/curves/secp256k1'; -import { keccak_256 } from '@noble/hashes/sha3'; -import { musigCrypto } from '../crypto/index.js'; -import { Outpoint } from '../transaction/utxo.js'; -import { getZoneForAddress } from '../utils/index.js'; +import { keccak256, musigCrypto } from '../crypto/index.js'; +import { Outpoint, UTXO, denominations } from '../transaction/utxo.js'; import { AllowedCoinType, Zone } from '../constants/index.js'; import { Mnemonic } from './mnemonic.js'; import { PaymentCodePrivate, PaymentCodePublic, PC_VERSION, validatePaymentCode } from './payment-codes.js'; @@ -23,6 +21,7 @@ import { BIP32Factory } from './bip32/bip32.js'; 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'; /** * @property {Outpoint} outpoint - The outpoint object. @@ -31,7 +30,7 @@ import ecc from '@bitcoinerlab/secp256k1'; * @property {number} [account] - The account number (optional). * @interface OutpointInfo */ -interface OutpointInfo { +export interface OutpointInfo { outpoint: Outpoint; address: string; zone: Zone; @@ -54,7 +53,7 @@ interface paymentCodeInfo { * @property {NeuteredAddressInfo[]} gapChangeAddresses - Array of gap change addresses. * @interface SerializedQiHDWallet */ -interface SerializedQiHDWallet extends SerializedHDWallet { +export interface SerializedQiHDWallet extends SerializedHDWallet { outpoints: OutpointInfo[]; changeAddresses: NeuteredAddressInfo[]; gapAddresses: NeuteredAddressInfo[]; @@ -224,101 +223,200 @@ export class QiHDWallet extends AbstractHDWallet { if (!txobj.txInputs || txobj.txInputs.length == 0 || !txobj.txOutputs) throw new Error('Invalid UTXO transaction, missing inputs or outputs'); - const hash = keccak_256(txobj.unsignedSerialized); + const hash = getBytes(keccak256(txobj.unsignedSerialized)); + + const shouldUseSchnorrSignature = (inputs: TxInput[]): boolean => { + if (inputs.length === 1) return true; + const firstPubKey = inputs[0].pubkey; + return inputs.every((input) => input.pubkey === firstPubKey); + }; let signature: string; - if (txobj.txInputs.length == 1) { + if (shouldUseSchnorrSignature(txobj.txInputs)) { signature = this.createSchnorrSignature(txobj.txInputs[0], hash); } else { signature = this.createMuSigSignature(txobj, hash); } - txobj.signature = signature; return txobj.serialized; } /** - * Sends a transaction using the traditional method (compatible with AbstractHDWallet). + * Gets the balance for the specified zone. * - * @param tx The transaction request. + * @param {Zone} zone - The zone to get the balance for. + * @returns {bigint} The total balance for the zone. + */ + public getBalanceForZone(zone: Zone): bigint { + this.validateZone(zone); + + return this._outpoints + .filter((outpoint) => outpoint.zone === zone) + .reduce((total, outpoint) => { + const denominationValue = denominations[outpoint.outpoint.denomination]; + return total + denominationValue; + }, BigInt(0)); + } + + /** + * Converts outpoints for a specific zone to UTXO format. + * + * @param {Zone} zone - The zone to filter outpoints for. + * @returns {UTXO[]} An array of UTXO objects. */ - public async sendTransaction(tx: QiTransactionRequest): Promise; + private outpointsToUTXOs(zone: Zone): UTXO[] { + this.validateZone(zone); + return this._outpoints + .filter((outpointInfo) => outpointInfo.zone === zone) + .map((outpointInfo) => { + const utxo = new UTXO(); + utxo.txhash = outpointInfo.outpoint.txhash; + utxo.index = outpointInfo.outpoint.index; + utxo.address = outpointInfo.address; + utxo.denomination = outpointInfo.outpoint.denomination; + return utxo; + }); + } /** - * Sends a transaction using payment codes and specific parameters. + * Sends a transaction using the traditional method (compatible with AbstractHDWallet). * - * @param recipientPaymentCode The payment code of the recipient. - * @param amount The amount to send. - * @param originZone The origin zone of the transaction. - * @param destinationZone The destination zone of the transaction. + * @param tx The transaction request. */ public async sendTransaction( recipientPaymentCode: string, amount: bigint, originZone: Zone, destinationZone: Zone, - ): Promise; - - /** - * Implementation of the sendTransaction method. - */ - public async sendTransaction(...args: any[]): Promise { + ): Promise { if (!this.provider) { throw new Error('Provider is not set'); } + // This is the new method call (recipientPaymentCode, amount, originZone, destinationZone) + if (!validatePaymentCode(recipientPaymentCode)) { + throw new Error('Invalid payment code'); + } + if (amount <= 0) { + throw new Error('Amount must be greater than 0'); + } + if (!Object.values(Zone).includes(originZone) || !Object.values(Zone).includes(destinationZone)) { + throw new Error('Invalid zone'); + } - if (args.length === 1 && typeof args[0] === 'object') { - // This is the traditional method call (tx: TransactionRequest) - const tx = args[0] as QiTransactionRequest; - if (!tx.txInputs || tx.txInputs.length === 0) { - throw new Error('Transaction has no inputs'); - } - const input = tx.txInputs[0]; - const address = computeAddress(input.pubkey); - const shard = getZoneForAddress(address); - if (!shard) { - throw new Error(`Address ${address} not found in any shard`); - } + // 1. Check the wallet has enough balance in the originating zone to send the transaction + const balance = this.getBalanceForZone(originZone); + if (balance < amount) { + throw new Error(`Insufficient balance in the originating zone: want ${amount} Qi got ${balance} Qi`); + } - // verify all inputs are from the same shard - if (tx.txInputs.some((input) => getZoneForAddress(computeAddress(input.pubkey)) !== shard)) { - throw new Error('All inputs must be from the same shard'); - } - const signedTx = await this.signTransaction(tx); - return await this.provider.broadcastTransaction(shard, signedTx); - } else if (args.length === 4) { - // This is the new method call (recipientPaymentCode, amount, originZone, destinationZone) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [recipientPaymentCode, amount, originZone, destinationZone] = args; - // !TODO: Implement the logic for sending a transaction using payment codes - if (!validatePaymentCode(recipientPaymentCode)) { - throw new Error('Invalid payment code'); - } - if (amount <= 0) { - throw new Error('Amount must be greater than 0'); - } - if (!Object.values(Zone).includes(originZone) || !Object.values(Zone).includes(destinationZone)) { - throw new Error('Invalid zone'); - } + // 2. Select the UXTOs from the specified zone to use as inputs, and generate the spend and change outputs + const zoneUTXOs = this.outpointsToUTXOs(originZone); + const fewestCoinSelector = new FewestCoinSelector(zoneUTXOs); - // 1. Check the wallet has enough balance in the originating zone to send the transaction + const spendTarget: bigint = amount; + let selection = fewestCoinSelector.performSelection(spendTarget); - // 2. Use the FewestCoinSelector.perform method to select the UXTOs from the specified zone to use as inputs, - // and generate the spend and change outputs + // 3. Generate as many unused addresses as required to populate the spend outputs + const sendAddresses: string[] = []; + for (let i = 0; i < selection.spendOutputs.length; i++) { + sendAddresses.push(await this.getNextSendAddress(recipientPaymentCode, destinationZone)); + } + // 4. Generate as many addresses as required to populate the change outputs + const changeAddresses: string[] = []; + for (let i = 0; i < selection.changeOutputs.length; i++) { + changeAddresses.push((await this.getNextChangeAddress(0, originZone)).address); + } + // 5. Create the transaction and sign it using the signTransaction method - // 3. Use the generateSendAddress method to generate as many unused addresses as required to populate the spend outputs + // 5.1 Fetch the public keys for the input addresses + let inputPubKeys = selection.inputs.map((input) => this.getAddressInfo(input.address)?.pubKey); + if (inputPubKeys.some((pubkey) => !pubkey)) { + throw new Error('Missing public key for input address'); + } - // 4. Use the getNextChangeAddress method to generate as many addresses as required to populate the change outputs + const chainId = (await this.provider.getNetwork()).chainId; + let tx = await this.prepareTransaction( + selection, + inputPubKeys.map((pubkey) => pubkey!), + sendAddresses, + changeAddresses, + Number(chainId), + ); - // 5. Create the transaction and sign it using the signTransaction method + const gasLimit = await this.provider.estimateGas(tx); + const feeData = await this.provider.getFeeData(originZone, false); - // 6. Broadcast the transaction to the network using the provider + // 5.6 Calculate total fee for the transaction using the gasLimit, gasPrice, maxFeePerGas and maxPriorityFeePerGas + const totalFee = + gasLimit * (feeData.gasPrice ?? 1n) + (feeData.maxFeePerGas ?? 0n) + (feeData.maxPriorityFeePerGas ?? 0n); - throw new Error('Payment code sendTransaction not implemented'); - } else { - throw new Error('Invalid arguments for sendTransaction'); + // Get new selection with increased fee + selection = fewestCoinSelector.increaseFee(totalFee); + + // 5.7 Determine if new addresses are needed for the change outputs + const changeAddressesNeeded = selection.changeOutputs.length > changeAddresses.length; + if (changeAddressesNeeded) { + for (let i = 0; i < selection.changeOutputs.length; i++) { + changeAddresses.push((await this.getNextChangeAddress(0, originZone)).address); + } + } + + const spendAddressesNeeded = selection.spendOutputs.length > sendAddresses.length; + if (spendAddressesNeeded) { + for (let i = 0; i < selection.spendOutputs.length; i++) { + sendAddresses.push(await this.getNextSendAddress(recipientPaymentCode, destinationZone)); + } } + + inputPubKeys = selection.inputs.map((input) => this.getAddressInfo(input.address)?.pubKey); + + tx = await this.prepareTransaction( + selection, + inputPubKeys.map((pubkey) => pubkey!), + sendAddresses, + changeAddresses, + Number(chainId), + ); + + // 5.6 Sign the transaction + const signedTx = await this.signTransaction(tx); + + // 6. Broadcast the transaction to the network using the provider + return this.provider.broadcastTransaction(originZone, signedTx); + } + + private async prepareTransaction( + selection: SelectedCoinsResult, + inputPubKeys: string[], + sendAddresses: string[], + changeAddresses: string[], + chainId: number, + ): Promise { + const tx = new QiTransaction(); + tx.txInputs = selection.inputs.map((input, index) => ({ + txhash: input.txhash!, + index: 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, + })); + + tx.txOutputs = [...senderOutputs, ...changeOutputs].map((output) => ({ + address: output.address, + denomination: output.denomination!, + })); + tx.chainId = chainId; + return tx; } /** @@ -587,7 +685,7 @@ export class QiHDWallet extends AbstractHDWallet { public async signMessage(address: string, message: string | Uint8Array): Promise { const addrNode = this._getHDNodeForAddress(address); const privKey = addrNode.privateKey; - const digest = keccak_256(message); + const digest = keccak256(message); const signature = schnorr.sign(digest, getBytes(privKey)); return hexlify(signature); }