Skip to content

Commit 10e5b5b

Browse files
committed
Add allowImplicitFungibleTokenBurn option to TransactionBuilder
1 parent 1653c2f commit 10e5b5b

File tree

4 files changed

+95
-13
lines changed

4 files changed

+95
-13
lines changed

packages/cashscript/src/TransactionBuilder.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface TransactionBuilderOptions {
4040
provider: NetworkProvider;
4141
maximumFeeSatoshis?: bigint;
4242
maximumFeeSatsPerByte?: number;
43+
allowImplicitFungibleTokenBurn?: boolean;
4344
}
4445

4546
const DEFAULT_SEQUENCE = 0xfffffffe;
@@ -50,11 +51,16 @@ export class TransactionBuilder {
5051
public outputs: Output[] = [];
5152

5253
public locktime: number = 0;
54+
public options: TransactionBuilderOptions;
5355

5456
constructor(
55-
private options: TransactionBuilderOptions,
57+
options: TransactionBuilderOptions,
5658
) {
5759
this.provider = options.provider;
60+
this.options = {
61+
allowImplicitFungibleTokenBurn: options.allowImplicitFungibleTokenBurn ?? false,
62+
...options,
63+
};
5864
}
5965

6066
addInput(utxo: Utxo, unlocker: Unlocker, options?: InputOptions): this {
@@ -114,15 +120,42 @@ export class TransactionBuilder {
114120

115121
if (this.options.maximumFeeSatsPerByte) {
116122
const transactionSize = encodeTransaction(transaction).byteLength;
117-
const feePerByte = Number(fee) / transactionSize;
123+
const feePerByte = Number((Number(fee) / transactionSize).toFixed(2));
118124

119125
if (feePerByte > this.options.maximumFeeSatsPerByte) {
120126
throw new Error(`Transaction fee per byte of ${feePerByte} is higher than max fee per byte of ${this.options.maximumFeeSatsPerByte}`);
121127
}
122128
}
123129
}
124130

131+
private checkFungibleTokenBurn(): void {
132+
if (this.options.allowImplicitFungibleTokenBurn) return;
133+
134+
const tokenInputAmounts: Record<string, bigint> = {};
135+
const tokenOutputAmounts: Record<string, bigint> = {};
136+
137+
for (const input of this.inputs) {
138+
if (input.token?.amount) {
139+
tokenInputAmounts[input.token.category] = (tokenInputAmounts[input.token.category] || 0n) + input.token.amount;
140+
}
141+
}
142+
for (const output of this.outputs) {
143+
if (output.token?.amount) {
144+
tokenOutputAmounts[output.token.category] = (tokenOutputAmounts[output.token.category] || 0n) + output.token.amount;
145+
}
146+
}
147+
148+
for (const [category, inputAmount] of Object.entries(tokenInputAmounts)) {
149+
const outputAmount = tokenOutputAmounts[category] || 0n;
150+
if (outputAmount < inputAmount) {
151+
throw new Error(`Implicit burning of fungible tokens for category ${category} is not allowed (input amount: ${inputAmount}, output amount: ${outputAmount}). If this is intended, set allowImplicitFungibleTokenBurn to true.`);
152+
}
153+
}
154+
}
155+
125156
buildLibauthTransaction(): LibauthTransaction {
157+
this.checkFungibleTokenBurn();
158+
126159
const inputs: LibauthTransaction['inputs'] = this.inputs.map((utxo) => ({
127160
outpointIndex: utxo.vout,
128161
outpointTransactionHash: hexToBin(utxo.txid),

packages/cashscript/test/e2e/P2PKH-tokens.test.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,24 +195,18 @@ describe('P2PKH-tokens', () => {
195195

196196
it('should throw an error when trying to send a token the contract doesn\'t have', async () => {
197197
const contractUtxos = await p2pkhInstance.getUtxos();
198-
const tokenUtxo = contractUtxos.find(isFungibleTokenUtxo);
199198
const nonTokenUtxos = contractUtxos.filter(isNonTokenUtxo);
200199

201-
if (!tokenUtxo) {
202-
throw new Error('No token UTXO found with fungible tokens');
203-
}
204-
205200
const to = p2pkhInstance.tokenAddress;
206201
const amount = 1000n;
207-
const token = { ...tokenUtxo.token!, category: '0000000000000000000000000000000000000000000000000000000000000000' };
202+
const token = { category: '0000000000000000000000000000000000000000000000000000000000000000', amount: 100n };
208203
const fee = 1000n;
209-
const fullBchBalance = nonTokenUtxos.reduce((total, utxo) => total + utxo.satoshis, 0n) + tokenUtxo.satoshis;
204+
const fullBchBalance = nonTokenUtxos.reduce((total, utxo) => total + utxo.satoshis, 0n);
210205
const changeAmount = fullBchBalance - fee - amount;
211206

212207
const unlocker = p2pkhInstance.unlock.spend(alicePub, new SignatureTemplate(alicePriv));
213208
const txPromise = new TransactionBuilder({ provider })
214209
.addInputs(nonTokenUtxos, unlocker)
215-
.addInput(tokenUtxo, unlocker)
216210
.addOutput({ to, amount, token })
217211
.addOutput({ to, amount: changeAmount })
218212
.send();
@@ -251,8 +245,59 @@ describe('P2PKH-tokens', () => {
251245
);
252246
});
253247

254-
it.todo('cannot burn fungible tokens when allowImplicitFungibleTokenBurn is false (default)');
255-
it.todo('can burn fungible tokens when allowImplicitFungibleTokenBurn is true');
248+
it('cannot burn fungible tokens when allowImplicitFungibleTokenBurn is false (default)', async () => {
249+
const contractUtxos = await p2pkhInstance.getUtxos();
250+
const tokenUtxo = contractUtxos.find(isFungibleTokenUtxo);
251+
const nonTokenUtxos = contractUtxos.filter(isNonTokenUtxo);
252+
253+
if (!tokenUtxo) {
254+
throw new Error('No token UTXO found with fungible tokens');
255+
}
256+
257+
const to = p2pkhInstance.tokenAddress;
258+
const amount = 1000n;
259+
const token = { ...tokenUtxo.token!, amount: tokenUtxo.token!.amount - 1n };
260+
const fee = 1000n;
261+
const fullBchBalance = nonTokenUtxos.reduce((total, utxo) => total + utxo.satoshis, 0n) + tokenUtxo.satoshis;
262+
const changeAmount = fullBchBalance - fee - amount;
263+
264+
const unlocker = p2pkhInstance.unlock.spend(alicePub, new SignatureTemplate(alicePriv));
265+
const txPromise = new TransactionBuilder({ provider })
266+
.addInputs(nonTokenUtxos, unlocker)
267+
.addInput(tokenUtxo, unlocker)
268+
.addOutput({ to, amount, token })
269+
.addOutput({ to, amount: changeAmount })
270+
.send();
271+
272+
await expect(txPromise).rejects.toThrow('Implicit burning of fungible tokens for category');
273+
});
274+
275+
it('can burn fungible tokens when allowImplicitFungibleTokenBurn is true', async () => {
276+
const contractUtxos = await p2pkhInstance.getUtxos();
277+
const tokenUtxo = contractUtxos.find(isFungibleTokenUtxo);
278+
const nonTokenUtxos = contractUtxos.filter(isNonTokenUtxo);
279+
280+
if (!tokenUtxo) {
281+
throw new Error('No token UTXO found with fungible tokens');
282+
}
283+
284+
const to = p2pkhInstance.tokenAddress;
285+
const amount = 1000n;
286+
const token = { ...tokenUtxo.token!, amount: tokenUtxo.token!.amount - 1n };
287+
const fee = 1000n;
288+
const fullBchBalance = nonTokenUtxos.reduce((total, utxo) => total + utxo.satoshis, 0n) + tokenUtxo.satoshis;
289+
const changeAmount = fullBchBalance - fee - amount;
290+
291+
const unlocker = p2pkhInstance.unlock.spend(alicePub, new SignatureTemplate(alicePriv));
292+
const txPromise = new TransactionBuilder({ provider, allowImplicitFungibleTokenBurn: true })
293+
.addInputs(nonTokenUtxos, unlocker)
294+
.addInput(tokenUtxo, unlocker)
295+
.addOutput({ to, amount, token })
296+
.addOutput({ to, amount: changeAmount })
297+
.send();
298+
299+
await expect(txPromise).resolves.toBeDefined();
300+
});
256301
});
257302
});
258303

website/docs/releases/release-notes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ title: Release Notes
1010
- :boom: **BREAKING**: Remove deprecated "old" transaction builder (`contract.functions`).
1111
- :boom: **BREAKING**: No longer seed the MockNetworkProvider with any test UTXOs.
1212
- :boom: **BREAKING**: Replace `setMaxFee()` method on `TransactionBuilder` with `TransactionBuilderOptions` on the constructor.
13-
- :sparkles: Add `maximumFeeSatsPerByte` option to `TransactionBuilder` constructor.
13+
- :sparkles: Add `maximumFeeSatsPerByte` and `allowImplicitFungibleTokenBurn` options to `TransactionBuilder` constructor.
1414
- :sparkles: Add a configurable `vmTarget` option to `MockNetworkProvider`.
1515
- :sparkles: Add support for ECDSA signatures in contract unlockers for `sig` and `datasig` parameters.
1616
- :sparkles: Add `signMessageHash()` method to `SignatureTemplate` to allow for signing of non-transaction messages.

website/docs/sdk/transaction-builder.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ The `maximumFeeSatoshis` option is used to specify the maximum fee for the trans
4545

4646
The `maximumFeeSatsPerByte` option is used to specify the maximum fee per byte for the transaction. If this fee is exceeded, an error will be thrown when building the transaction.
4747

48+
#### allowImplicitFungibleTokenBurn
49+
50+
The `allowImplicitFungibleTokenBurn` option is used to specify whether implicit burning of fungible tokens is allowed (default: `false`). If this is set to `true`, the transaction builder will not throw an error when burning fungible tokens.
51+
4852
## Transaction Building
4953

5054
### addInput()

0 commit comments

Comments
 (0)