From fdd967275381c45cc4e194b7d01c3f9d35e98a00 Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Thu, 26 Jun 2025 07:47:37 +0000 Subject: [PATCH 1/4] Track UTXOs in MockNetworkProvider --- .../src/network/MockNetworkProvider.ts | 50 +++++++++++++++- .../e2e/network/MockNetworkProvider.test.ts | 59 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index 3f6603a3..3af699a8 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -1,4 +1,4 @@ -import { binToHex, hexToBin } from '@bitauth/libauth'; +import { binToHex, decodeTransaction, hexToBin, isHex } from '@bitauth/libauth'; import { sha256 } from '@cashscript/utils'; import { Utxo, Network } from '../interfaces.js'; import NetworkProvider from './NetworkProvider.js'; @@ -10,6 +10,7 @@ const bobAddress = 'bchtest:qz6q5gqnxdldkr07xpls5474mmzmlesd6qnux4skuc'; const carolAddress = 'bchtest:qqsr7nqwe6rq5crj63gy5gdqchpnwmguusmr7tfmsj'; export default class MockNetworkProvider implements NetworkProvider { + // we use lockingBytecode as the key for utxoMap to make cashaddresses and tokenaddresses interchangeable private utxoMap: Record = {}; private transactionMap: Record = {}; public network: Network = Network.MOCKNET; @@ -45,11 +46,54 @@ export default class MockNetworkProvider implements NetworkProvider { const txid = binToHex(sha256(sha256(transactionBin)).reverse()); this.transactionMap[txid] = txHex; + + const decoded = decodeTransaction(transactionBin); + if (typeof decoded === 'string') { + throw new Error(`${decoded}`); + } + + // remove (spend) UTXOs from the map + for (const input of decoded.inputs) { + for (const address of Object.keys(this.utxoMap)) { + const utxos = this.utxoMap[address]; + const index = utxos.findIndex( + (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex + ); + + if (index !== -1) { + // Remove the UTXO from the map + utxos.splice(index, 1); + this.utxoMap[address] = utxos; + break; // Exit loop after finding and removing the UTXO + } + if (utxos.length === 0) { + delete this.utxoMap[address]; // Clean up empty address entries + } + } + } + + // add new UTXOs to the map + for (const [index, output] of decoded.outputs.entries()) { + this.addUtxo(binToHex(output.lockingBytecode), { + txid: txid, + vout: index, + satoshis: output.valueSatoshis, + token: output.token && { + ...output.token, + category: binToHex(output.token.category), + nft: output.token.nft && { + ...output.token.nft, + commitment: binToHex(output.token.nft.commitment), + }, + }, + }); + } + return txid; } - addUtxo(address: string, utxo: Utxo): void { - const lockingBytecode = binToHex(addressToLockScript(address)); + addUtxo(addressOrLockingBytecode: string, utxo: Utxo): void { + const lockingBytecode = isHex(addressOrLockingBytecode) ? addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode)); if (!this.utxoMap[lockingBytecode]) { this.utxoMap[lockingBytecode] = []; } diff --git a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts new file mode 100644 index 00000000..2711d501 --- /dev/null +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -0,0 +1,59 @@ +import { binToHex } from '@bitauth/libauth'; +import { Contract, MockNetworkProvider, SignatureTemplate } from '../../../src/index.js'; +import { TransactionBuilder } from '../../../src/TransactionBuilder.js'; +import { addressToLockScript, randomUtxo } from '../../../src/utils.js'; +import p2pkhArtifact from '../../fixture/p2pkh.artifact.js'; +import { + aliceAddress, + alicePkh, + alicePriv, + alicePub, + bobAddress, +} from '../../fixture/vars.js'; +import { itOrSkip } from '../../test-util.js'; + +describe('Transaction Builder', () => { + const provider = new MockNetworkProvider(); + + let p2pkhInstance: Contract; + + beforeAll(() => { + p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider }); + }); + + beforeEach(() => { + provider.reset(); + }); + + itOrSkip(!process.env.TESTS_USE_MOCKNET, 'MockNetworkProvider should keep track of utxo set - remove spent utxos and add newly created', async () => { + expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + + // add by address + provider.addUtxo(aliceAddress, randomUtxo()); + // add by locking bytecode + provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo()); + + const aliceUtxos = await provider.getUtxos(aliceAddress); + const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address); + + expect(aliceUtxos).toHaveLength(1); + expect(p2pkhUtxos).toHaveLength(1); + + const sigTemplate = new SignatureTemplate(alicePriv); + + // spend both utxos to bob + new TransactionBuilder({provider}) + .addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(alicePub, sigTemplate)) + .addInput(aliceUtxos[0], sigTemplate.unlockP2PKH()) + .addOutput({ to: bobAddress, amount: 1000n }) + .send(); + + // utxos should be removed from the provider + expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + + // utxo should be added to bob + expect(await provider.getUtxos(bobAddress)).toHaveLength(1); + }); +}); From 48a7918b32eff054dce273692dbeff7aac6bf8bc Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Mon, 14 Jul 2025 16:07:19 +0000 Subject: [PATCH 2/4] Throw on an attempt to submit the same transaction twice --- .../src/network/MockNetworkProvider.ts | 26 +++++++++++------- .../e2e/network/MockNetworkProvider.test.ts | 27 ++++++++++++++----- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index 3af699a8..1a47c4eb 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -10,7 +10,7 @@ const bobAddress = 'bchtest:qz6q5gqnxdldkr07xpls5474mmzmlesd6qnux4skuc'; const carolAddress = 'bchtest:qqsr7nqwe6rq5crj63gy5gdqchpnwmguusmr7tfmsj'; export default class MockNetworkProvider implements NetworkProvider { - // we use lockingBytecode as the key for utxoMap to make cashaddresses and tokenaddresses interchangeable + // we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable private utxoMap: Record = {}; private transactionMap: Record = {}; public network: Network = Network.MOCKNET; @@ -45,6 +45,11 @@ export default class MockNetworkProvider implements NetworkProvider { const transactionBin = hexToBin(txHex); const txid = binToHex(sha256(sha256(transactionBin)).reverse()); + + if (this.transactionMap[txid]) { + throw new Error(`Transaction with txid ${txid} was already submitted: txn-mempool-conflict`); + } + this.transactionMap[txid] = txHex; const decoded = decodeTransaction(transactionBin); @@ -54,21 +59,23 @@ export default class MockNetworkProvider implements NetworkProvider { // remove (spend) UTXOs from the map for (const input of decoded.inputs) { - for (const address of Object.keys(this.utxoMap)) { - const utxos = this.utxoMap[address]; + for (const lockingBytecodeHex of Object.keys(this.utxoMap)) { + const utxos = this.utxoMap[lockingBytecodeHex]; const index = utxos.findIndex( - (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex + (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, ); if (index !== -1) { // Remove the UTXO from the map utxos.splice(index, 1); - this.utxoMap[address] = utxos; + this.utxoMap[lockingBytecodeHex] = utxos; + + if (utxos.length === 0) { + delete this.utxoMap[lockingBytecodeHex]; // Clean up empty address entries + } + break; // Exit loop after finding and removing the UTXO } - if (utxos.length === 0) { - delete this.utxoMap[address]; // Clean up empty address entries - } } } @@ -93,7 +100,8 @@ export default class MockNetworkProvider implements NetworkProvider { } addUtxo(addressOrLockingBytecode: string, utxo: Utxo): void { - const lockingBytecode = isHex(addressOrLockingBytecode) ? addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode)); + const lockingBytecode = isHex(addressOrLockingBytecode) ? + addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode)); if (!this.utxoMap[lockingBytecode]) { this.utxoMap[lockingBytecode] = []; } diff --git a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts index 2711d501..2d0712ed 100644 --- a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -30,9 +30,13 @@ describe('Transaction Builder', () => { expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); // add by address - provider.addUtxo(aliceAddress, randomUtxo()); + provider.addUtxo(aliceAddress, randomUtxo({ + satoshis: 1100n, + })); // add by locking bytecode - provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo()); + provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ + satoshis: 1100n, + })); const aliceUtxos = await provider.getUtxos(aliceAddress); const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address); @@ -43,11 +47,18 @@ describe('Transaction Builder', () => { const sigTemplate = new SignatureTemplate(alicePriv); // spend both utxos to bob - new TransactionBuilder({provider}) - .addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(alicePub, sigTemplate)) - .addInput(aliceUtxos[0], sigTemplate.unlockP2PKH()) - .addOutput({ to: bobAddress, amount: 1000n }) - .send(); + const builder = new TransactionBuilder({ provider }) + .addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate)) + .addInputs(aliceUtxos, sigTemplate.unlockP2PKH()) + .addOutput({ to: bobAddress, amount: 2000n }); + + const tx = builder.build(); + + // try to send invalid transaction + await expect(provider.sendRawTransaction(tx.slice(0, -2))).rejects.toThrow('Error reading transaction.'); + + // send valid transaction + await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow(); // utxos should be removed from the provider expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); @@ -55,5 +66,7 @@ describe('Transaction Builder', () => { // utxo should be added to bob expect(await provider.getUtxos(bobAddress)).toHaveLength(1); + + await expect(provider.sendRawTransaction(tx)).rejects.toThrow('txn-mempool-conflict'); }); }); From 0e36a81f543d190170b7a88076181aadb2553eda Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Wed, 6 Aug 2025 15:46:14 +0200 Subject: [PATCH 3/4] Add option for 'updateUtxoSet' to MockNetworkProvider and add test for it --- .../src/network/MockNetworkProvider.ts | 74 +++++----- .../e2e/network/MockNetworkProvider.test.ts | 129 ++++++++++++------ 2 files changed, 129 insertions(+), 74 deletions(-) diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index 1a47c4eb..c9a00fa5 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -9,6 +9,12 @@ const aliceAddress = 'bchtest:qpgjmwev3spwlwkgmyjrr2s2cvlkkzlewq62mzgjnp'; const bobAddress = 'bchtest:qz6q5gqnxdldkr07xpls5474mmzmlesd6qnux4skuc'; const carolAddress = 'bchtest:qqsr7nqwe6rq5crj63gy5gdqchpnwmguusmr7tfmsj'; +interface MockNetworkProviderOptions { + updateUtxoSet?: boolean; +} + +// We are setting the default updateUtxoSet to 'false' so that it doesn't break the current behaviour +// TODO: in a future breaking release we want to set this to 'true' by default export default class MockNetworkProvider implements NetworkProvider { // we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable private utxoMap: Record = {}; @@ -16,7 +22,7 @@ export default class MockNetworkProvider implements NetworkProvider { public network: Network = Network.MOCKNET; public blockHeight: number = 133700; - constructor() { + constructor(public options?: MockNetworkProviderOptions) { for (let i = 0; i < 3; i += 1) { this.addUtxo(aliceAddress, randomUtxo()); this.addUtxo(bobAddress, randomUtxo()); @@ -57,43 +63,45 @@ export default class MockNetworkProvider implements NetworkProvider { throw new Error(`${decoded}`); } - // remove (spend) UTXOs from the map - for (const input of decoded.inputs) { - for (const lockingBytecodeHex of Object.keys(this.utxoMap)) { - const utxos = this.utxoMap[lockingBytecodeHex]; - const index = utxos.findIndex( - (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, - ); - - if (index !== -1) { - // Remove the UTXO from the map - utxos.splice(index, 1); - this.utxoMap[lockingBytecodeHex] = utxos; - - if (utxos.length === 0) { - delete this.utxoMap[lockingBytecodeHex]; // Clean up empty address entries + if (this.options?.updateUtxoSet) { + // remove (spend) UTXOs from the map + for (const input of decoded.inputs) { + for (const lockingBytecodeHex of Object.keys(this.utxoMap)) { + const utxos = this.utxoMap[lockingBytecodeHex]; + const index = utxos.findIndex( + (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, + ); + + if (index !== -1) { + // Remove the UTXO from the map + utxos.splice(index, 1); + this.utxoMap[lockingBytecodeHex] = utxos; + + if (utxos.length === 0) { + delete this.utxoMap[lockingBytecodeHex]; // Clean up empty address entries + } + + break; // Exit loop after finding and removing the UTXO } - - break; // Exit loop after finding and removing the UTXO } } - } - // add new UTXOs to the map - for (const [index, output] of decoded.outputs.entries()) { - this.addUtxo(binToHex(output.lockingBytecode), { - txid: txid, - vout: index, - satoshis: output.valueSatoshis, - token: output.token && { - ...output.token, - category: binToHex(output.token.category), - nft: output.token.nft && { - ...output.token.nft, - commitment: binToHex(output.token.nft.commitment), + // add new UTXOs to the map + for (const [index, output] of decoded.outputs.entries()) { + this.addUtxo(binToHex(output.lockingBytecode), { + txid: txid, + vout: index, + satoshis: output.valueSatoshis, + token: output.token && { + ...output.token, + category: binToHex(output.token.category), + nft: output.token.nft && { + ...output.token.nft, + commitment: binToHex(output.token.nft.commitment), + }, }, - }, - }); + }); + } } return txid; diff --git a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts index 2d0712ed..a431b4fe 100644 --- a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -10,63 +10,110 @@ import { alicePub, bobAddress, } from '../../fixture/vars.js'; -import { itOrSkip } from '../../test-util.js'; +import { describeOrSkip } from '../../test-util.js'; -describe('Transaction Builder', () => { - const provider = new MockNetworkProvider(); +describeOrSkip(!process.env.TESTS_USE_CHIPNET, 'MockNetworkProvider', () => { + describe('when updateUtxoSet is true', () => { + const provider = new MockNetworkProvider({ updateUtxoSet: true }); - let p2pkhInstance: Contract; + let p2pkhInstance: Contract; - beforeAll(() => { - p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider }); - }); + beforeAll(() => { + p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider }); + }); + + beforeEach(() => { + provider.reset(); + }); + + it('should keep track of utxo set changes', async () => { + expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + + // add by address & locking bytecode + provider.addUtxo(aliceAddress, randomUtxo({ satoshis: 1100n })); + provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ satoshis: 1100n })); + + const aliceUtxos = await provider.getUtxos(aliceAddress); + const bobUtxos = await provider.getUtxos(bobAddress); + const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address); + + expect(aliceUtxos).toHaveLength(1); + expect(bobUtxos).toHaveLength(0); + expect(p2pkhUtxos).toHaveLength(1); + + const sigTemplate = new SignatureTemplate(alicePriv); - beforeEach(() => { - provider.reset(); + // spend both utxos to bob + const builder = new TransactionBuilder({ provider }) + .addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate)) + .addInputs(aliceUtxos, sigTemplate.unlockP2PKH()) + .addOutput({ to: bobAddress, amount: 2000n }); + + const tx = builder.build(); + + // try to send invalid transaction + await expect(provider.sendRawTransaction(tx.slice(0, -2))).rejects.toThrow('Error reading transaction.'); + + // send valid transaction + await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow(); + + // utxos should be removed from the provider + expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + + // utxo should be added to bob + expect(await provider.getUtxos(bobAddress)).toHaveLength(1); + + await expect(provider.sendRawTransaction(tx)).rejects.toThrow('txn-mempool-conflict'); + }); }); - itOrSkip(!process.env.TESTS_USE_MOCKNET, 'MockNetworkProvider should keep track of utxo set - remove spent utxos and add newly created', async () => { - expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); - expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + describe('when updateUtxoSet is default (false)', () => { + const provider = new MockNetworkProvider(); + + let p2pkhInstance: Contract; - // add by address - provider.addUtxo(aliceAddress, randomUtxo({ - satoshis: 1100n, - })); - // add by locking bytecode - provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ - satoshis: 1100n, - })); + beforeAll(() => { + p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider }); + }); - const aliceUtxos = await provider.getUtxos(aliceAddress); - const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address); + beforeEach(() => { + provider.reset(); + }); - expect(aliceUtxos).toHaveLength(1); - expect(p2pkhUtxos).toHaveLength(1); + it('should not keep track of utxo set changes', async () => { + expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); - const sigTemplate = new SignatureTemplate(alicePriv); + // add by address & locking bytecode + provider.addUtxo(aliceAddress, randomUtxo({ satoshis: 1100n })); + provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ satoshis: 1100n })); - // spend both utxos to bob - const builder = new TransactionBuilder({ provider }) - .addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate)) - .addInputs(aliceUtxos, sigTemplate.unlockP2PKH()) - .addOutput({ to: bobAddress, amount: 2000n }); + const aliceUtxos = await provider.getUtxos(aliceAddress); + const bobUtxos = await provider.getUtxos(bobAddress); + const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address); - const tx = builder.build(); + expect(aliceUtxos).toHaveLength(1); + expect(bobUtxos).toHaveLength(0); + expect(p2pkhUtxos).toHaveLength(1); - // try to send invalid transaction - await expect(provider.sendRawTransaction(tx.slice(0, -2))).rejects.toThrow('Error reading transaction.'); + const sigTemplate = new SignatureTemplate(alicePriv); - // send valid transaction - await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow(); + // spend both utxos to bob + const builder = new TransactionBuilder({ provider }) + .addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate)) + .addInputs(aliceUtxos, sigTemplate.unlockP2PKH()) + .addOutput({ to: bobAddress, amount: 2000n }); - // utxos should be removed from the provider - expect(await provider.getUtxos(aliceAddress)).toHaveLength(0); - expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0); + const tx = builder.build(); - // utxo should be added to bob - expect(await provider.getUtxos(bobAddress)).toHaveLength(1); + await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow(); - await expect(provider.sendRawTransaction(tx)).rejects.toThrow('txn-mempool-conflict'); + // utxos should not be removed from the provider + expect(await provider.getUtxos(aliceAddress)).toHaveLength(1); + expect(await provider.getUtxos(bobAddress)).toHaveLength(0); + expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(1); + }); }); }); From 58cc65c9e0be8d266d039904e1ea6c0e583033e7 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Wed, 6 Aug 2025 16:32:01 +0200 Subject: [PATCH 4/4] Refactor MockNetworkProvider utxo storage and update utxo set code --- .../src/network/MockNetworkProvider.ts | 96 ++++++++----------- packages/cashscript/src/utils.ts | 19 ++-- .../e2e/network/MockNetworkProvider.test.ts | 2 +- 3 files changed, 53 insertions(+), 64 deletions(-) diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index c9a00fa5..667b2ca7 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -1,8 +1,8 @@ -import { binToHex, decodeTransaction, hexToBin, isHex } from '@bitauth/libauth'; +import { binToHex, decodeTransactionUnsafe, hexToBin, isHex } from '@bitauth/libauth'; import { sha256 } from '@cashscript/utils'; import { Utxo, Network } from '../interfaces.js'; import NetworkProvider from './NetworkProvider.js'; -import { addressToLockScript, randomUtxo } from '../utils.js'; +import { addressToLockScript, libauthTokenDetailsToCashScriptTokenDetails, randomUtxo } from '../utils.js'; // redeclare the addresses from vars.ts instead of importing them const aliceAddress = 'bchtest:qpgjmwev3spwlwkgmyjrr2s2cvlkkzlewq62mzgjnp'; @@ -10,19 +10,22 @@ const bobAddress = 'bchtest:qz6q5gqnxdldkr07xpls5474mmzmlesd6qnux4skuc'; const carolAddress = 'bchtest:qqsr7nqwe6rq5crj63gy5gdqchpnwmguusmr7tfmsj'; interface MockNetworkProviderOptions { - updateUtxoSet?: boolean; + updateUtxoSet: boolean; } // We are setting the default updateUtxoSet to 'false' so that it doesn't break the current behaviour // TODO: in a future breaking release we want to set this to 'true' by default export default class MockNetworkProvider implements NetworkProvider { // we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable - private utxoMap: Record = {}; + private utxoSet: Array<[string, Utxo]> = []; private transactionMap: Record = {}; public network: Network = Network.MOCKNET; public blockHeight: number = 133700; + public options: MockNetworkProviderOptions; + + constructor(options?: Partial) { + this.options = { updateUtxoSet: false, ...options }; - constructor(public options?: MockNetworkProviderOptions) { for (let i = 0; i < 3; i += 1) { this.addUtxo(aliceAddress, randomUtxo()); this.addUtxo(bobAddress, randomUtxo()); @@ -31,8 +34,8 @@ export default class MockNetworkProvider implements NetworkProvider { } async getUtxos(address: string): Promise { - const lockingBytecode = binToHex(addressToLockScript(address)); - return this.utxoMap[lockingBytecode] ?? []; + const addressLockingBytecode = binToHex(addressToLockScript(address)); + return this.utxoSet.filter(([lockingBytecode]) => lockingBytecode === addressLockingBytecode).map(([, utxo]) => utxo); } setBlockHeight(newBlockHeight: number): void { @@ -52,73 +55,54 @@ export default class MockNetworkProvider implements NetworkProvider { const txid = binToHex(sha256(sha256(transactionBin)).reverse()); - if (this.transactionMap[txid]) { - throw new Error(`Transaction with txid ${txid} was already submitted: txn-mempool-conflict`); + if (this.options.updateUtxoSet && this.transactionMap[txid]) { + throw new Error(`Transaction with txid ${txid} was already submitted`); } this.transactionMap[txid] = txHex; - const decoded = decodeTransaction(transactionBin); - if (typeof decoded === 'string') { - throw new Error(`${decoded}`); - } + // If updateUtxoSet is false, we don't need to update the utxo set, and just return the txid + if (!this.options.updateUtxoSet) return txid; - if (this.options?.updateUtxoSet) { - // remove (spend) UTXOs from the map - for (const input of decoded.inputs) { - for (const lockingBytecodeHex of Object.keys(this.utxoMap)) { - const utxos = this.utxoMap[lockingBytecodeHex]; - const index = utxos.findIndex( - (utxo) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, - ); - - if (index !== -1) { - // Remove the UTXO from the map - utxos.splice(index, 1); - this.utxoMap[lockingBytecodeHex] = utxos; - - if (utxos.length === 0) { - delete this.utxoMap[lockingBytecodeHex]; // Clean up empty address entries - } - - break; // Exit loop after finding and removing the UTXO - } - } - } + const decodedTransaction = decodeTransactionUnsafe(transactionBin); + + decodedTransaction.inputs.forEach((input) => { + const utxoIndex = this.utxoSet.findIndex( + ([, utxo]) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, + ); - // add new UTXOs to the map - for (const [index, output] of decoded.outputs.entries()) { - this.addUtxo(binToHex(output.lockingBytecode), { - txid: txid, - vout: index, - satoshis: output.valueSatoshis, - token: output.token && { - ...output.token, - category: binToHex(output.token.category), - nft: output.token.nft && { - ...output.token.nft, - commitment: binToHex(output.token.nft.commitment), - }, - }, - }); + // TODO: we should check what error a BCHN node throws, so we can throw the same error here + if (utxoIndex === -1) { + throw new Error(`UTXO not found for input ${input.outpointIndex} of transaction ${txid}`); } - } + + this.utxoSet.splice(utxoIndex, 1); + }); + + decodedTransaction.outputs.forEach((output, vout) => { + this.addUtxo(binToHex(output.lockingBytecode), { + txid, + vout, + satoshis: output.valueSatoshis, + token: output.token && libauthTokenDetailsToCashScriptTokenDetails(output.token), + }); + }); return txid; } + // Note: the user can technically add the same UTXO multiple times (txid + vout), to the same or different addresses + // but we don't check for this in the sendRawTransaction method. We might want to prevent duplicates from being added + // in the first place. addUtxo(addressOrLockingBytecode: string, utxo: Utxo): void { const lockingBytecode = isHex(addressOrLockingBytecode) ? addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode)); - if (!this.utxoMap[lockingBytecode]) { - this.utxoMap[lockingBytecode] = []; - } - this.utxoMap[lockingBytecode].push(utxo); + this.utxoSet.push([lockingBytecode, utxo]); } reset(): void { - this.utxoMap = {}; + this.utxoSet = []; this.transactionMap = {}; } } diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index 0771083c..17dd5391 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -33,6 +33,7 @@ import { TokenDetails, AddressType, UnlockableUtxo, + LibauthTokenDetails, } from './interfaces.js'; import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js'; import { @@ -113,13 +114,17 @@ export function libauthOutputToCashScriptOutput(output: LibauthOutput): Output { return { to: output.lockingBytecode, amount: output.valueSatoshis, - token: output.token && { - ...output.token, - category: binToHex(output.token.category), - nft: output.token.nft && { - ...output.token.nft, - commitment: binToHex(output.token.nft.commitment), - }, + token: output.token && libauthTokenDetailsToCashScriptTokenDetails(output.token), + }; +} + +export function libauthTokenDetailsToCashScriptTokenDetails(token: LibauthTokenDetails): TokenDetails { + return { + ...token, + category: binToHex(token.category), + nft: token.nft && { + ...token.nft, + commitment: binToHex(token.nft.commitment), }, }; } diff --git a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts index a431b4fe..e6f8aa19 100644 --- a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -65,7 +65,7 @@ describeOrSkip(!process.env.TESTS_USE_CHIPNET, 'MockNetworkProvider', () => { // utxo should be added to bob expect(await provider.getUtxos(bobAddress)).toHaveLength(1); - await expect(provider.sendRawTransaction(tx)).rejects.toThrow('txn-mempool-conflict'); + await expect(provider.sendRawTransaction(tx)).rejects.toThrow('already submitted'); }); });