Skip to content

Commit e42192b

Browse files
mainnet-patrkalis
andauthored
Track UTXOs in MockNetworkProvider (#317)
Co-authored-by: Rosco Kalis <roscokalis@gmail.com>
1 parent 280c566 commit e42192b

File tree

3 files changed

+188
-20
lines changed

3 files changed

+188
-20
lines changed
Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
1-
import { binToHex, hexToBin } from '@bitauth/libauth';
1+
import { binToHex, decodeTransactionUnsafe, hexToBin, isHex } from '@bitauth/libauth';
22
import { sha256 } from '@cashscript/utils';
33
import { Utxo, Network } from '../interfaces.js';
44
import NetworkProvider from './NetworkProvider.js';
5-
import { addressToLockScript, randomUtxo } from '../utils.js';
5+
import { addressToLockScript, libauthTokenDetailsToCashScriptTokenDetails, randomUtxo } from '../utils.js';
66

77
// redeclare the addresses from vars.ts instead of importing them
88
const aliceAddress = 'bchtest:qpgjmwev3spwlwkgmyjrr2s2cvlkkzlewq62mzgjnp';
99
const bobAddress = 'bchtest:qz6q5gqnxdldkr07xpls5474mmzmlesd6qnux4skuc';
1010
const carolAddress = 'bchtest:qqsr7nqwe6rq5crj63gy5gdqchpnwmguusmr7tfmsj';
1111

12+
interface MockNetworkProviderOptions {
13+
updateUtxoSet: boolean;
14+
}
15+
16+
// We are setting the default updateUtxoSet to 'false' so that it doesn't break the current behaviour
17+
// TODO: in a future breaking release we want to set this to 'true' by default
1218
export default class MockNetworkProvider implements NetworkProvider {
13-
private utxoMap: Record<string, Utxo[]> = {};
19+
// we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable
20+
private utxoSet: Array<[string, Utxo]> = [];
1421
private transactionMap: Record<string, string> = {};
1522
public network: Network = Network.MOCKNET;
1623
public blockHeight: number = 133700;
24+
public options: MockNetworkProviderOptions;
25+
26+
constructor(options?: Partial<MockNetworkProviderOptions>) {
27+
this.options = { updateUtxoSet: false, ...options };
1728

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

2636
async getUtxos(address: string): Promise<Utxo[]> {
27-
const lockingBytecode = binToHex(addressToLockScript(address));
28-
return this.utxoMap[lockingBytecode] ?? [];
37+
const addressLockingBytecode = binToHex(addressToLockScript(address));
38+
return this.utxoSet.filter(([lockingBytecode]) => lockingBytecode === addressLockingBytecode).map(([, utxo]) => utxo);
2939
}
3040

3141
setBlockHeight(newBlockHeight: number): void {
@@ -44,21 +54,55 @@ export default class MockNetworkProvider implements NetworkProvider {
4454
const transactionBin = hexToBin(txHex);
4555

4656
const txid = binToHex(sha256(sha256(transactionBin)).reverse());
57+
58+
if (this.options.updateUtxoSet && this.transactionMap[txid]) {
59+
throw new Error(`Transaction with txid ${txid} was already submitted`);
60+
}
61+
4762
this.transactionMap[txid] = txHex;
63+
64+
// If updateUtxoSet is false, we don't need to update the utxo set, and just return the txid
65+
if (!this.options.updateUtxoSet) return txid;
66+
67+
const decodedTransaction = decodeTransactionUnsafe(transactionBin);
68+
69+
decodedTransaction.inputs.forEach((input) => {
70+
const utxoIndex = this.utxoSet.findIndex(
71+
([, utxo]) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex,
72+
);
73+
74+
// TODO: we should check what error a BCHN node throws, so we can throw the same error here
75+
if (utxoIndex === -1) {
76+
throw new Error(`UTXO not found for input ${input.outpointIndex} of transaction ${txid}`);
77+
}
78+
79+
this.utxoSet.splice(utxoIndex, 1);
80+
});
81+
82+
decodedTransaction.outputs.forEach((output, vout) => {
83+
this.addUtxo(binToHex(output.lockingBytecode), {
84+
txid,
85+
vout,
86+
satoshis: output.valueSatoshis,
87+
token: output.token && libauthTokenDetailsToCashScriptTokenDetails(output.token),
88+
});
89+
});
90+
4891
return txid;
4992
}
5093

51-
addUtxo(address: string, utxo: Utxo): void {
52-
const lockingBytecode = binToHex(addressToLockScript(address));
53-
if (!this.utxoMap[lockingBytecode]) {
54-
this.utxoMap[lockingBytecode] = [];
55-
}
94+
// Note: the user can technically add the same UTXO multiple times (txid + vout), to the same or different addresses
95+
// but we don't check for this in the sendRawTransaction method. We might want to prevent duplicates from being added
96+
// in the first place.
97+
addUtxo(addressOrLockingBytecode: string, utxo: Utxo): void {
98+
const lockingBytecode = isHex(addressOrLockingBytecode) ?
99+
addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode));
56100

57-
this.utxoMap[lockingBytecode].push(utxo);
101+
this.utxoSet.push([lockingBytecode, utxo]);
58102
}
59103

60104
reset(): void {
61-
this.utxoMap = {};
105+
this.utxoSet = [];
62106
this.transactionMap = {};
63107
}
64108
}

packages/cashscript/src/utils.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
TokenDetails,
3434
AddressType,
3535
UnlockableUtxo,
36+
LibauthTokenDetails,
3637
} from './interfaces.js';
3738
import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js';
3839
import {
@@ -113,13 +114,17 @@ export function libauthOutputToCashScriptOutput(output: LibauthOutput): Output {
113114
return {
114115
to: output.lockingBytecode,
115116
amount: output.valueSatoshis,
116-
token: output.token && {
117-
...output.token,
118-
category: binToHex(output.token.category),
119-
nft: output.token.nft && {
120-
...output.token.nft,
121-
commitment: binToHex(output.token.nft.commitment),
122-
},
117+
token: output.token && libauthTokenDetailsToCashScriptTokenDetails(output.token),
118+
};
119+
}
120+
121+
export function libauthTokenDetailsToCashScriptTokenDetails(token: LibauthTokenDetails): TokenDetails {
122+
return {
123+
...token,
124+
category: binToHex(token.category),
125+
nft: token.nft && {
126+
...token.nft,
127+
commitment: binToHex(token.nft.commitment),
123128
},
124129
};
125130
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { binToHex } from '@bitauth/libauth';
2+
import { Contract, MockNetworkProvider, SignatureTemplate } from '../../../src/index.js';
3+
import { TransactionBuilder } from '../../../src/TransactionBuilder.js';
4+
import { addressToLockScript, randomUtxo } from '../../../src/utils.js';
5+
import p2pkhArtifact from '../../fixture/p2pkh.artifact.js';
6+
import {
7+
aliceAddress,
8+
alicePkh,
9+
alicePriv,
10+
alicePub,
11+
bobAddress,
12+
} from '../../fixture/vars.js';
13+
import { describeOrSkip } from '../../test-util.js';
14+
15+
describeOrSkip(!process.env.TESTS_USE_CHIPNET, 'MockNetworkProvider', () => {
16+
describe('when updateUtxoSet is true', () => {
17+
const provider = new MockNetworkProvider({ updateUtxoSet: true });
18+
19+
let p2pkhInstance: Contract<typeof p2pkhArtifact>;
20+
21+
beforeAll(() => {
22+
p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider });
23+
});
24+
25+
beforeEach(() => {
26+
provider.reset();
27+
});
28+
29+
it('should keep track of utxo set changes', async () => {
30+
expect(await provider.getUtxos(aliceAddress)).toHaveLength(0);
31+
expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0);
32+
33+
// add by address & locking bytecode
34+
provider.addUtxo(aliceAddress, randomUtxo({ satoshis: 1100n }));
35+
provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ satoshis: 1100n }));
36+
37+
const aliceUtxos = await provider.getUtxos(aliceAddress);
38+
const bobUtxos = await provider.getUtxos(bobAddress);
39+
const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address);
40+
41+
expect(aliceUtxos).toHaveLength(1);
42+
expect(bobUtxos).toHaveLength(0);
43+
expect(p2pkhUtxos).toHaveLength(1);
44+
45+
const sigTemplate = new SignatureTemplate(alicePriv);
46+
47+
// spend both utxos to bob
48+
const builder = new TransactionBuilder({ provider })
49+
.addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate))
50+
.addInputs(aliceUtxos, sigTemplate.unlockP2PKH())
51+
.addOutput({ to: bobAddress, amount: 2000n });
52+
53+
const tx = builder.build();
54+
55+
// try to send invalid transaction
56+
await expect(provider.sendRawTransaction(tx.slice(0, -2))).rejects.toThrow('Error reading transaction.');
57+
58+
// send valid transaction
59+
await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow();
60+
61+
// utxos should be removed from the provider
62+
expect(await provider.getUtxos(aliceAddress)).toHaveLength(0);
63+
expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0);
64+
65+
// utxo should be added to bob
66+
expect(await provider.getUtxos(bobAddress)).toHaveLength(1);
67+
68+
await expect(provider.sendRawTransaction(tx)).rejects.toThrow('already submitted');
69+
});
70+
});
71+
72+
describe('when updateUtxoSet is default (false)', () => {
73+
const provider = new MockNetworkProvider();
74+
75+
let p2pkhInstance: Contract<typeof p2pkhArtifact>;
76+
77+
beforeAll(() => {
78+
p2pkhInstance = new Contract(p2pkhArtifact, [alicePkh], { provider });
79+
});
80+
81+
beforeEach(() => {
82+
provider.reset();
83+
});
84+
85+
it('should not keep track of utxo set changes', async () => {
86+
expect(await provider.getUtxos(aliceAddress)).toHaveLength(0);
87+
expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(0);
88+
89+
// add by address & locking bytecode
90+
provider.addUtxo(aliceAddress, randomUtxo({ satoshis: 1100n }));
91+
provider.addUtxo(binToHex(addressToLockScript(p2pkhInstance.address)), randomUtxo({ satoshis: 1100n }));
92+
93+
const aliceUtxos = await provider.getUtxos(aliceAddress);
94+
const bobUtxos = await provider.getUtxos(bobAddress);
95+
const p2pkhUtxos = await provider.getUtxos(p2pkhInstance.address);
96+
97+
expect(aliceUtxos).toHaveLength(1);
98+
expect(bobUtxos).toHaveLength(0);
99+
expect(p2pkhUtxos).toHaveLength(1);
100+
101+
const sigTemplate = new SignatureTemplate(alicePriv);
102+
103+
// spend both utxos to bob
104+
const builder = new TransactionBuilder({ provider })
105+
.addInputs(p2pkhUtxos, p2pkhInstance.unlock.spend(alicePub, sigTemplate))
106+
.addInputs(aliceUtxos, sigTemplate.unlockP2PKH())
107+
.addOutput({ to: bobAddress, amount: 2000n });
108+
109+
const tx = builder.build();
110+
111+
await expect(provider.sendRawTransaction(tx)).resolves.not.toThrow();
112+
113+
// utxos should not be removed from the provider
114+
expect(await provider.getUtxos(aliceAddress)).toHaveLength(1);
115+
expect(await provider.getUtxos(bobAddress)).toHaveLength(0);
116+
expect(await provider.getUtxos(p2pkhInstance.address)).toHaveLength(1);
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)