Skip to content

Track UTXOs in MockNetworkProvider #317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions packages/cashscript/src/network/MockNetworkProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, Utxo[]> = {};
// 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<string, string> = {};
public network: Network = Network.MOCKNET;
public blockHeight: number = 133700;
public options: MockNetworkProviderOptions;

constructor(options?: Partial<MockNetworkProviderOptions>) {
this.options = { updateUtxoSet: false, ...options };

constructor() {
for (let i = 0; i < 3; i += 1) {
this.addUtxo(aliceAddress, randomUtxo());
this.addUtxo(bobAddress, randomUtxo());
Expand All @@ -24,8 +34,8 @@ export default class MockNetworkProvider implements NetworkProvider {
}

async getUtxos(address: string): Promise<Utxo[]> {
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 {
Expand All @@ -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 = {};
}
}
19 changes: 12 additions & 7 deletions packages/cashscript/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
TokenDetails,
AddressType,
UnlockableUtxo,
LibauthTokenDetails,
} from './interfaces.js';
import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js';
import {
Expand Down Expand Up @@ -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),
},
};
}
Expand Down
119 changes: 119 additions & 0 deletions packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof p2pkhArtifact>;

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<typeof p2pkhArtifact>;

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);
});
});
});