diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index 3f6603a3..667b2ca7 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -1,21 +1,31 @@ -import { binToHex, hexToBin } 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'; 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 { - private utxoMap: Record = {}; + // we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable + 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() { for (let i = 0; i < 3; i += 1) { this.addUtxo(aliceAddress, randomUtxo()); this.addUtxo(bobAddress, randomUtxo()); @@ -24,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 { @@ -44,21 +54,55 @@ export default class MockNetworkProvider implements NetworkProvider { const transactionBin = hexToBin(txHex); const txid = binToHex(sha256(sha256(transactionBin)).reverse()); + + if (this.options.updateUtxoSet && this.transactionMap[txid]) { + throw new Error(`Transaction with txid ${txid} was already submitted`); + } + this.transactionMap[txid] = txHex; + + // If updateUtxoSet is false, we don't need to update the utxo set, and just return the txid + if (!this.options.updateUtxoSet) return txid; + + const decodedTransaction = decodeTransactionUnsafe(transactionBin); + + decodedTransaction.inputs.forEach((input) => { + const utxoIndex = this.utxoSet.findIndex( + ([, utxo]) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, + ); + + // 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; } - addUtxo(address: string, utxo: Utxo): void { - const lockingBytecode = binToHex(addressToLockScript(address)); - if (!this.utxoMap[lockingBytecode]) { - this.utxoMap[lockingBytecode] = []; - } + // 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)); - 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 new file mode 100644 index 00000000..e6f8aa19 --- /dev/null +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -0,0 +1,119 @@ +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 { describeOrSkip } from '../../test-util.js'; + +describeOrSkip(!process.env.TESTS_USE_CHIPNET, 'MockNetworkProvider', () => { + describe('when updateUtxoSet is true', () => { + const provider = new MockNetworkProvider({ updateUtxoSet: true }); + + let p2pkhInstance: Contract; + + 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); + + // 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('already submitted'); + }); + }); + + describe('when updateUtxoSet is default (false)', () => { + const provider = new MockNetworkProvider(); + + let p2pkhInstance: Contract; + + beforeAll(() => { + p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider }); + }); + + beforeEach(() => { + provider.reset(); + }); + + 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); + + // 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); + + // 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(); + + await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow(); + + // 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); + }); + }); +});