Skip to content

Commit df2ba98

Browse files
rkalismr-zwets
andauthored
Fix bug with placeholder unlocker in wc utils (#326)
Co-authored-by: Mathieu Geukens <[email protected]>
1 parent ecd8804 commit df2ba98

File tree

7 files changed

+260
-113
lines changed

7 files changed

+260
-113
lines changed

packages/cashscript/src/TransactionBuilder.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -224,20 +224,15 @@ export class TransactionBuilder {
224224
}
225225

226226
generateWcTransactionObject(options?: WcTransactionOptions): WcTransactionObject {
227-
const inputs = this.inputs;
228-
if (!inputs.every(input => isStandardUnlockableUtxo(input))) {
229-
throw new Error('All inputs must be StandardUnlockableUtxos to generate the wcSourceOutputs');
230-
}
231-
232227
const encodedTransaction = this.build();
233228
const transaction = decodeTransactionUnsafe(hexToBin(encodedTransaction));
234229

235-
const libauthSourceOutputs = generateLibauthSourceOutputs(inputs);
230+
const libauthSourceOutputs = generateLibauthSourceOutputs(this.inputs);
236231
const sourceOutputs: WcSourceOutput[] = libauthSourceOutputs.map((sourceOutput, index) => {
237232
return {
238233
...sourceOutput,
239234
...transaction.inputs[index],
240-
...getWcContractInfo(inputs[index]),
235+
...getWcContractInfo(this.inputs[index]),
241236
};
242237
});
243238
return { ...options, transaction, sourceOutputs };

packages/cashscript/src/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export interface P2PKHUnlocker extends Unlocker {
5656

5757
export type StandardUnlocker = ContractUnlocker | P2PKHUnlocker;
5858

59+
export type PlaceholderP2PKHUnlocker = Unlocker & { placeholder: true };
60+
61+
5962
export function isContractUnlocker(unlocker: Unlocker): unlocker is ContractUnlocker {
6063
return 'contract' in unlocker;
6164
}
@@ -68,6 +71,10 @@ export function isStandardUnlocker(unlocker: Unlocker): unlocker is StandardUnlo
6871
return isContractUnlocker(unlocker) || isP2PKHUnlocker(unlocker);
6972
}
7073

74+
export function isPlaceholderUnlocker(unlocker: Unlocker): unlocker is PlaceholderP2PKHUnlocker {
75+
return 'placeholder' in unlocker;
76+
}
77+
7178
export interface UtxoP2PKH extends Utxo {
7279
template: SignatureTemplate;
7380
}

packages/cashscript/src/walletconnect-utils.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { StandardUnlockableUtxo, LibauthOutput, Unlocker } from './interfaces.js';
1+
import { type LibauthOutput, isContractUnlocker, type PlaceholderP2PKHUnlocker, type UnlockableUtxo } from './interfaces.js';
22
import { type AbiFunction, type Artifact, scriptToBytecode } from '@cashscript/utils';
33
import { cashAddressToLockingBytecode, type Input, type TransactionCommon } from '@bitauth/libauth';
44

@@ -11,7 +11,7 @@ export interface WcTransactionOptions {
1111
}
1212

1313
export interface WcTransactionObject {
14-
transaction: TransactionCommon | string;
14+
transaction: TransactionCommon; // spec also allows for a tx hex string but we use the libauth transaction object
1515
sourceOutputs: WcSourceOutput[];
1616
broadcast?: boolean;
1717
userPrompt?: string;
@@ -27,9 +27,9 @@ export interface WcContractInfo {
2727
}
2828
}
2929

30-
export function getWcContractInfo(input: StandardUnlockableUtxo): WcContractInfo | {} {
30+
export function getWcContractInfo(input: UnlockableUtxo): WcContractInfo | {} {
3131
// If the input does not have a contract unlocker, return an empty object
32-
if (!('contract' in input.unlocker)) return {};
32+
if (!(isContractUnlocker(input.unlocker))) return {};
3333
const contract = input.unlocker.contract;
3434
const abiFunctionName = input.unlocker.abiFunction?.name;
3535
const abiFunction = contract.artifact.abi.find(abi => abi.name === abiFunctionName);
@@ -49,7 +49,7 @@ export function getWcContractInfo(input: StandardUnlockableUtxo): WcContractInfo
4949
export const placeholderSignature = (): Uint8Array => Uint8Array.from(Array(65));
5050
export const placeholderPublicKey = (): Uint8Array => Uint8Array.from(Array(33));
5151

52-
export const placeholderP2PKHUnlocker = (userAddress: string): Unlocker => {
52+
export const placeholderP2PKHUnlocker = (userAddress: string): PlaceholderP2PKHUnlocker => {
5353
const decodeAddressResult = cashAddressToLockingBytecode(userAddress);
5454

5555
if (typeof decodeAddressResult === 'string') {
@@ -60,5 +60,6 @@ export const placeholderP2PKHUnlocker = (userAddress: string): Unlocker => {
6060
return {
6161
generateLockingBytecode: () => lockingBytecode,
6262
generateUnlockingBytecode: () => Uint8Array.from(Array(0)),
63+
placeholder: true,
6364
};
6465
};

packages/cashscript/test/TransactionBuilder.test.ts

Lines changed: 128 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { decodeTransactionUnsafe, hexToBin, stringify } from '@bitauth/libauth';
2-
import { Contract, SignatureTemplate, ElectrumNetworkProvider, MockNetworkProvider } from '../src/index.js';
2+
import { Contract, SignatureTemplate, ElectrumNetworkProvider, MockNetworkProvider, placeholderP2PKHUnlocker, placeholderPublicKey, placeholderSignature } from '../src/index.js';
33
import {
44
bobAddress,
55
bobPub,
@@ -16,6 +16,7 @@ import p2pkhArtifact from './fixture/p2pkh.artifact.js';
1616
import twtArtifact from './fixture/transfer_with_timeout.artifact.js';
1717
import { TransactionBuilder } from '../src/TransactionBuilder.js';
1818
import { gatherUtxos, getTxOutputs } from './test-util.js';
19+
import { generateWcTransactionObjectFixture } from './fixture/walletconnect/fixtures.js';
1920

2021
describe('Transaction Builder', () => {
2122
const provider = process.env.TESTS_USE_MOCKNET
@@ -136,131 +137,160 @@ describe('Transaction Builder', () => {
136137
});
137138
});
138139

139-
it('should build a transaction that can spend from 2 different contracts and P2PKH + OP_RETURN', async () => {
140-
const fee = 1000n;
140+
describe('test TransactionBuilder.build', () => {
141+
it('should build a transaction that can spend from 2 different contracts and P2PKH + OP_RETURN', async () => {
142+
const fee = 1000n;
141143

142-
const carolUtxos = (await provider.getUtxos(carolAddress)).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
143-
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
144-
const twtUtxos = (await twtInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
144+
const carolUtxos = (await provider.getUtxos(carolAddress)).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
145+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
146+
const twtUtxos = (await twtInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
145147

146-
const change = carolUtxos[0].satoshis - fee;
147-
const dustAmount = calculateDust({ to: carolAddress, amount: change });
148+
const change = carolUtxos[0].satoshis - fee;
149+
const dustAmount = calculateDust({ to: carolAddress, amount: change });
148150

149-
const outputs = [
150-
{ to: p2pkhInstance.address, amount: p2pkhUtxos[0].satoshis },
151-
{ to: twtInstance.address, amount: twtUtxos[0].satoshis },
152-
...(change > dustAmount ? [{ to: carolAddress, amount: change }] : []),
153-
];
151+
const outputs = [
152+
{ to: p2pkhInstance.address, amount: p2pkhUtxos[0].satoshis },
153+
{ to: twtInstance.address, amount: twtUtxos[0].satoshis },
154+
...(change > dustAmount ? [{ to: carolAddress, amount: change }] : []),
155+
];
154156

155-
if (change < 0) {
156-
throw new Error('Not enough funds to send transaction');
157-
}
157+
if (change < 0) {
158+
throw new Error('Not enough funds to send transaction');
159+
}
158160

159-
const tx = new TransactionBuilder({ provider })
160-
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
161-
.addInput(twtUtxos[0], twtInstance.unlock.transfer(new SignatureTemplate(carolPriv)))
162-
.addInput(carolUtxos[0], new SignatureTemplate(carolPriv).unlockP2PKH())
163-
.addOpReturnOutput(['Hello new transaction builder'])
164-
.addOutputs(outputs)
165-
.build();
161+
const tx = new TransactionBuilder({ provider })
162+
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
163+
.addInput(twtUtxos[0], twtInstance.unlock.transfer(new SignatureTemplate(carolPriv)))
164+
.addInput(carolUtxos[0], new SignatureTemplate(carolPriv).unlockP2PKH())
165+
.addOpReturnOutput(['Hello new transaction builder'])
166+
.addOutputs(outputs)
167+
.build();
166168

167-
const txOutputs = getTxOutputs(decodeTransactionUnsafe(hexToBin(tx)));
168-
expect(txOutputs).toEqual(expect.arrayContaining(outputs));
169-
});
169+
const txOutputs = getTxOutputs(decodeTransactionUnsafe(hexToBin(tx)));
170+
expect(txOutputs).toEqual(expect.arrayContaining(outputs));
171+
});
172+
173+
it('should fail when fee is higher than maxFee', async () => {
174+
const fee = 2000n;
175+
const maxFee = 1000n;
176+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
170177

171-
it('should fail when fee is higher than maxFee', async () => {
172-
const fee = 2000n;
173-
const maxFee = 1000n;
174-
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
178+
const amount = p2pkhUtxos[0].satoshis - fee;
179+
const dustAmount = calculateDust({ to: p2pkhInstance.address, amount });
175180

176-
const amount = p2pkhUtxos[0].satoshis - fee;
177-
const dustAmount = calculateDust({ to: p2pkhInstance.address, amount });
181+
if (amount < dustAmount) {
182+
throw new Error('Not enough funds to send transaction');
183+
}
184+
185+
expect(() => {
186+
new TransactionBuilder({ provider })
187+
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
188+
.addOutput({ to: p2pkhInstance.address, amount })
189+
.setMaxFee(maxFee)
190+
.build();
191+
}).toThrow(`Transaction fee of ${fee} is higher than max fee of ${maxFee}`);
192+
});
193+
194+
it('should succeed when fee is lower than maxFee', async () => {
195+
const fee = 1000n;
196+
const maxFee = 2000n;
197+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
178198

179-
if (amount < dustAmount) {
180-
throw new Error('Not enough funds to send transaction');
181-
}
199+
const amount = p2pkhUtxos[0].satoshis - fee;
200+
const dustAmount = calculateDust({ to: p2pkhInstance.address, amount });
182201

183-
expect(() => {
184-
new TransactionBuilder({ provider })
202+
if (amount < dustAmount) {
203+
throw new Error('Not enough funds to send transaction');
204+
}
205+
206+
const tx = new TransactionBuilder({ provider })
185207
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
186208
.addOutput({ to: p2pkhInstance.address, amount })
187209
.setMaxFee(maxFee)
188210
.build();
189-
}).toThrow(`Transaction fee of ${fee} is higher than max fee of ${maxFee}`);
190-
});
191211

192-
it('should succeed when fee is lower than maxFee', async () => {
193-
const fee = 1000n;
194-
const maxFee = 2000n;
195-
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
196-
197-
const amount = p2pkhUtxos[0].satoshis - fee;
198-
const dustAmount = calculateDust({ to: p2pkhInstance.address, amount });
212+
expect(tx).toBeDefined();
213+
});
199214

200-
if (amount < dustAmount) {
201-
throw new Error('Not enough funds to send transaction');
202-
}
215+
// TODO: Consider improving error messages checked below to also include the input/output index
203216

204-
const tx = new TransactionBuilder({ provider })
205-
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
206-
.addOutput({ to: p2pkhInstance.address, amount })
207-
.setMaxFee(maxFee)
208-
.build();
217+
it('should fail when trying to send to invalid address', async () => {
218+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
209219

210-
expect(tx).toBeDefined();
211-
});
220+
expect(() => {
221+
new TransactionBuilder({ provider })
222+
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
223+
.addOutput({ to: bobAddress.slice(0, -1), amount: 1000n })
224+
.build();
225+
}).toThrow('CashAddress decoding error');
226+
});
212227

213-
// TODO: Consider improving error messages checked below to also include the input/output index
228+
it('should fail when trying to send tokens to non-token address', async () => {
229+
const tokenUtxo = (await p2pkhInstance.getUtxos()).find(isFungibleTokenUtxo)!;
214230

215-
it('should fail when trying to send to invalid address', async () => {
216-
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
231+
expect(() => {
232+
new TransactionBuilder({ provider })
233+
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
234+
.addOutput({ to: bobAddress, amount: 1000n, token: tokenUtxo.token })
235+
.build();
236+
}).toThrow('Tried to send tokens to an address without token support');
237+
});
217238

218-
expect(() => {
219-
new TransactionBuilder({ provider })
220-
.addInput(p2pkhUtxos[0], p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
221-
.addOutput({ to: bobAddress.slice(0, -1), amount: 1000n })
222-
.build();
223-
}).toThrow('CashAddress decoding error');
224-
});
239+
it('should fail when trying to send negative BCH amount or token amount', async () => {
240+
const tokenUtxo = (await p2pkhInstance.getUtxos()).find(isFungibleTokenUtxo)!;
241+
242+
expect(() => {
243+
new TransactionBuilder({ provider })
244+
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
245+
.addOutput({ to: bobTokenAddress, amount: -1000n, token: tokenUtxo.token })
246+
.build();
247+
}).toThrow('Tried to add an output with -1000 satoshis, which is less than the required minimum for this output-type');
248+
249+
expect(() => {
250+
new TransactionBuilder({ provider })
251+
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
252+
.addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: -1000n, category: tokenUtxo.token!.category } })
253+
.build();
254+
}).toThrow('Tried to add an output with -1000 tokens, which is invalid');
255+
});
225256

226-
it('should fail when trying to send tokens to non-token address', async () => {
227-
const tokenUtxo = (await p2pkhInstance.getUtxos()).find(isFungibleTokenUtxo)!;
257+
it('should fail when adding undefined input', async () => {
258+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
259+
const undefinedUtxo = p2pkhUtxos[1000];
228260

229-
expect(() => {
230-
new TransactionBuilder({ provider })
231-
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
232-
.addOutput({ to: bobAddress, amount: 1000n, token: tokenUtxo.token })
233-
.build();
234-
}).toThrow('Tried to send tokens to an address without token support');
261+
expect(() => {
262+
new TransactionBuilder({ provider })
263+
.addInput(undefinedUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
264+
.addOutput({ to: bobAddress, amount: 1000n })
265+
.build();
266+
}).toThrow('Input is undefined');
267+
});
235268
});
236269

237-
it('should fail when trying to send negative BCH amount or token amount', async () => {
238-
const tokenUtxo = (await p2pkhInstance.getUtxos()).find(isFungibleTokenUtxo)!;
270+
describe('test TransactionBuilder.generateWcTransactionObject', () => {
271+
it('should match the generateWcTransactionObjectFixture ', async () => {
272+
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
273+
const contractUtxo = p2pkhUtxos[0];
274+
const bobUtxos = await provider.getUtxos(bobAddress);
239275

240-
expect(() => {
241-
new TransactionBuilder({ provider })
242-
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
243-
.addOutput({ to: bobTokenAddress, amount: -1000n, token: tokenUtxo.token })
244-
.build();
245-
}).toThrow('Tried to add an output with -1000 satoshis, which is less than the required minimum for this output-type');
276+
const placeholderUnlocker = placeholderP2PKHUnlocker(bobAddress);
277+
const placeholderPubKey = placeholderPublicKey();
278+
const placeholderSig = placeholderSignature();
246279

247-
expect(() => {
248-
new TransactionBuilder({ provider })
249-
.addInput(tokenUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
250-
.addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: -1000n, category: tokenUtxo.token!.category } })
251-
.build();
252-
}).toThrow('Tried to add an output with -1000 tokens, which is invalid');
253-
});
280+
// use the CashScript SDK to construct a transaction
281+
const transactionBuilder = new TransactionBuilder({ provider })
282+
.addInput(contractUtxo, p2pkhInstance.unlock.spend(placeholderPubKey, placeholderSig))
283+
.addInput(bobUtxos[0], placeholderUnlocker)
284+
.addOutput({ to: bobAddress, amount: 100_000n });
254285

255-
it('should fail when adding undefined input', async () => {
256-
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
257-
const undefinedUtxo = p2pkhUtxos[1000];
286+
// Generate WalletConnect transaction object with custom 'broadcast' and 'userPrompt' options
287+
const wcTransactionObj = transactionBuilder.generateWcTransactionObject({
288+
broadcast: true,
289+
userPrompt: 'Example Contract transaction',
290+
});
258291

259-
expect(() => {
260-
new TransactionBuilder({ provider })
261-
.addInput(undefinedUtxo, p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)))
262-
.addOutput({ to: bobAddress, amount: 1000n })
263-
.build();
264-
}).toThrow('Input is undefined');
292+
const expectedResult = generateWcTransactionObjectFixture;
293+
expect(JSON.parse(stringify(wcTransactionObj))).toEqual(expectedResult);
294+
});
265295
});
266296
});

0 commit comments

Comments
 (0)