diff --git a/src/_tests/unit/mockProvider.ts b/src/_tests/unit/mockProvider.ts index 5045f95f..fb9723f8 100644 --- a/src/_tests/unit/mockProvider.ts +++ b/src/_tests/unit/mockProvider.ts @@ -30,6 +30,8 @@ export class MockProvider implements Provider { private _balances: Map = new Map(); private _lockedBalances: Map = new Map(); private _outpoints: Map> = new Map(); + private _estimateFeeForQi: Map = new Map(); + private _signedTransaction: string = ''; private _eventHandlers: Map< string, Array<{ @@ -47,11 +49,31 @@ export class MockProvider implements Provider { if (config?.blockNumber) this._blockNumber = config.blockNumber; } - // Helper methods to set up test state + // Helper methods to set the balanace of an address public setBalance(address: string, balance: bigint): void { this._balances.set(address.toLowerCase(), balance); } + // mock getBalance method + async getBalance(address: AddressLike, blockTag?: BlockTag): Promise { + return this._balances.get(address.toString().toLowerCase()) ?? BigInt(0); + } + + // helper method to set the estimated Qi fee for a transaction in the mock provider + public setEstimateFeeForQi(input: QiPerformActionTransaction, output: number): void { + const key = JSON.stringify(input, bigIntSerializer); + this._estimateFeeForQi.set(key, output); + } + + // mock estimateFeeForQi method + async estimateFeeForQi(input: QiPerformActionTransaction): Promise { + const key = JSON.stringify(input, bigIntSerializer); + + const fee = this._estimateFeeForQi.get(key) ?? 0; + return BigInt(fee); + } + + // Helper methods to set the transaction to be used by the mock provider public setTransaction(hash: string, tx: TransactionResponse): void { this._transactions.set(hash.toLowerCase(), tx); } @@ -60,11 +82,17 @@ export class MockProvider implements Provider { this._outpoints.set(address.toLowerCase(), outpoints); } - // Implementation of Provider interface methods + // Helper methods to set the network to be used by the mock provider + public setNetwork(network: Network): void { + this._network = network; + } + + // mock getNetwork method async getNetwork(): Promise { return this._network; } + // Helper methods to set the block number to be used by the mock provider public setBlock(key: string, block: Block): void { this._blocks.set(key, block); } @@ -81,11 +109,13 @@ export class MockProvider implements Provider { return this._blockNumber; } - async getBalance(address: AddressLike, blockTag?: BlockTag): Promise { - return this._balances.get(address.toString().toLowerCase()) ?? BigInt(0); + // helper method to inspect the signed transaction sent to the mock provider + public getSignedTransaction(): string { + return this._signedTransaction; } async broadcastTransaction(zone: Zone, signedTx: string, from?: AddressLike): Promise { + this._signedTransaction = signedTx; // Simulate transaction broadcast const type = decodeProtoTransaction(getBytes(signedTx)).type; @@ -109,11 +139,6 @@ export class MockProvider implements Provider { return this._outpoints.get(address.toString().toLowerCase()) ?? []; } - async estimateFeeForQi(tx: QiPerformActionTransaction): Promise { - // Return a mock fee for testing - return BigInt(1000); - } - async on(event: ProviderEvent, listener: Listener, zone?: Zone): Promise { const eventKey = this._getEventKey(event, zone); if (!this._eventHandlers.has(eventKey)) { @@ -307,3 +332,16 @@ export class MockProvider implements Provider { throw new Error('getLatestQuaiRate: Method not implemented.'); } } + +function bigIntSerializer(key: string, value: any): any { + // Handle null/undefined explicitly + if (value === null || value === undefined) { + return value; + } + // Handle bigint values + if (typeof value === 'bigint') { + return value.toString(); + } + // Return other values unchanged + return value; +} diff --git a/src/_tests/unit/qihdwallet-scan-and-send.unit.test.ts b/src/_tests/unit/qihdwallet-scan-and-send.unit.test.ts new file mode 100644 index 00000000..43750565 --- /dev/null +++ b/src/_tests/unit/qihdwallet-scan-and-send.unit.test.ts @@ -0,0 +1,186 @@ +import assert from 'assert'; +import { loadTests } from '../utils.js'; +import { + Mnemonic, + QiHDWallet, + Zone, + OutpointInfo, + Block, + QiAddressInfo, + Network, + QiTransaction, + getBytes, +} from '../../index.js'; +import { Outpoint } from '../../transaction/utxo.js'; +import { QiPerformActionTransaction } from '../../providers/abstract-provider.js'; +import { MockProvider } from './mockProvider.js'; +import { schnorr } from '@noble/curves/secp256k1'; + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +import dotenv from 'dotenv'; +const env = process.env.NODE_ENV || 'development'; +dotenv.config({ path: `.env.${env}` }); +dotenv.config({ path: `.env`, override: false }); + +interface ScanTestCase { + name: string; + mnemonic: string; + bob_mnemonic: string; + amount_to_send_to_bob: number; + provider_outpoints: Array<{ + address: string; + outpoints: Array; + }>; + provider_locked_balance: Array<{ + address: string; + balance: number; + }>; + provider_balance: Array<{ + address: string; + balance: number; + }>; + provider_blocks: Array<{ + key: string; + block: Block; + }>; + expected_external_addresses: Array; + expected_change_addresses: Array; + expected_outpoints_info: Array; + expected_balance: number; + provider_estimate_fee_for_qi: Array<{ + input: QiPerformActionTransaction; + output: number; + }>; + provider_network: Network; + provider_broadcast_transaction_receipt: string; + expected_signed_tx: string; +} + +describe('QiHDWallet scan and send transaction', async function () { + const tests = loadTests('qi-wallet-scan-and-send'); + + for (const test of tests) { + this.timeout(1200000); + + const mockProvider = new MockProvider(); + + // set the provider outpoints + for (const outpoint of test.provider_outpoints) { + mockProvider.setOutpoints(outpoint.address, outpoint.outpoints); + } + + // set the provider blocks + for (const block of test.provider_blocks) { + mockProvider.setBlock(block.key, block.block); + } + + // set the provider locked balace + for (const lockedBalance of test.provider_locked_balance) { + mockProvider.setLockedBalance(lockedBalance.address, BigInt(lockedBalance.balance)); + } + + // set the provider balance + for (const balance of test.provider_balance) { + mockProvider.setBalance(balance.address, BigInt(balance.balance)); + } + + // set the provider estimate fee for Qi + for (const estimateFeeForQi of test.provider_estimate_fee_for_qi) { + mockProvider.setEstimateFeeForQi(estimateFeeForQi.input, estimateFeeForQi.output); + } + + // set the provider network + mockProvider.setNetwork(test.provider_network); + + const mnemonic = Mnemonic.fromPhrase(test.mnemonic); + const aliceWallet = QiHDWallet.fromMnemonic(mnemonic); + aliceWallet.connect(mockProvider); + + const bobMnemonic = Mnemonic.fromPhrase(test.bob_mnemonic); + const bobWallet = QiHDWallet.fromMnemonic(bobMnemonic); + + const alicePaymentCode = aliceWallet.getPaymentCode(0); + const bobPaymentCode = bobWallet.getPaymentCode(0); + aliceWallet.openChannel(bobPaymentCode); + bobWallet.openChannel(alicePaymentCode); + + it('it scans Alice wallet with no errors', async function () { + try { + await aliceWallet.scan(Zone.Cyprus1); + assert.ok(true, '====> TESTING: scan completed'); + } catch (error) { + console.error('====> TESTING: error: ', error); + assert.fail('====> TESTING: error: ', error); + } + }); + it('validates expected Alice external addresses', async function () { + const externalAddresses = aliceWallet.getAddressesForZone(Zone.Cyprus1); + const sortedExternalAddresses = externalAddresses.sort((a, b) => a.address.localeCompare(b.address)); + const sortedExpectedExternalAddresses = test.expected_external_addresses.sort((a, b) => + a.address.localeCompare(b.address), + ); + assert.deepEqual(sortedExternalAddresses, sortedExpectedExternalAddresses); + }); + + it('validates expected Alice change addresses', async function () { + const changeAddresses = aliceWallet.getChangeAddressesForZone(Zone.Cyprus1); + const sortedChangeAddresses = changeAddresses.sort((a, b) => a.address.localeCompare(b.address)); + const sortedExpectedChangeAddresses = test.expected_change_addresses.sort((a, b) => + a.address.localeCompare(b.address), + ); + assert.deepEqual(sortedChangeAddresses, sortedExpectedChangeAddresses); + }); + + it('validates wallet balance', async function () { + const balance = await aliceWallet.getBalanceForZone(Zone.Cyprus1); + assert.equal(balance.toString(), test.expected_balance.toString()); + }); + + it('validates expected outpoints info', async function () { + assert.deepEqual(aliceWallet.getOutpoints(Zone.Cyprus1), test.expected_outpoints_info); + }); + + it('sends transaction', async function () { + const aliceToBobTx = await aliceWallet.sendTransaction( + bobPaymentCode, + BigInt(test.amount_to_send_to_bob), + Zone.Cyprus1, + Zone.Cyprus1, + ); + assert.ok(aliceToBobTx); + }); + + it('validate signed transaction', function () { + const signedTransaction = mockProvider.getSignedTransaction(); + const expectedSignedTx = test.expected_signed_tx; + + const tx = QiTransaction.from(signedTransaction); + const expectedTx = QiTransaction.from(expectedSignedTx); + + // compare everyhing but the hash and signature + assert.deepEqual(tx.txInputs, expectedTx.txInputs); + assert.deepEqual(tx.txOutputs, expectedTx.txOutputs); + assert.deepEqual(tx.type, expectedTx.type); + assert.deepEqual(tx.chainId, expectedTx.chainId); + + const valid = validateSignature(tx); + assert.ok(valid); + }); + } +}); + +function validateSignature(tx: QiTransaction): boolean { + const digest = tx.digest; + const signature = tx.signature; + const pubkey = tx.txInputs[0].pubkey; + + const pubkeyBytes = getBytes('0x' + pubkey.slice(4)); + const signatureBytes = getBytes(signature); + const hashBytes = getBytes(digest); + + return schnorr.verify(signatureBytes, hashBytes, pubkeyBytes); +} diff --git a/testcases/qi-wallet-scan-and-send.json.gz b/testcases/qi-wallet-scan-and-send.json.gz new file mode 100644 index 00000000..43d174b1 Binary files /dev/null and b/testcases/qi-wallet-scan-and-send.json.gz differ