Skip to content

Commit 2de0c81

Browse files
committed
Fixed regarding UTXO selection and fee calculation
- Fix fee calculation for P2PKH inputs - Do not add zero-value fungible tokens when selecting UTXOs - Do not add token UTXOs needlessly when amountNeeded === 0
1 parent fab1b9a commit 2de0c81

File tree

5 files changed

+132
-16
lines changed

5 files changed

+132
-16
lines changed

examples/common.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ if (typeof aliceNode === 'string') throw new Error();
1919
export const alicePub = secp256k1.derivePublicKeyCompressed(aliceNode.privateKey) as Uint8Array;
2020
export const alicePriv = aliceNode.privateKey;
2121
export const alicePkh = hash160(alicePub);
22-
export const aliceAddress = encodeCashAddress('bchtest', 'p2pkh', alicePkh);
22+
export const aliceAddress = encodeCashAddress('bchtest', 'p2pkhWithTokens', alicePkh);
2323

2424
// Derive Bob's private key, public key, public key hash and address
2525
const bobNode = deriveHdPath(rootNode, `${baseDerivationPath}/1`);
2626
if (typeof bobNode === 'string') throw new Error();
2727
export const bobPub = secp256k1.derivePublicKeyCompressed(bobNode.privateKey) as Uint8Array;
2828
export const bobPriv = bobNode.privateKey;
2929
export const bobPkh = hash160(bobPub);
30-
export const bobAddress = encodeCashAddress('bchtest', 'p2pkh', bobPkh);
30+
export const bobAddress = encodeCashAddress('bchtest', 'p2pkhWithTokens', bobPkh);
3131

3232
// Initialise a price oracle with a private key
3333
const oracleNode = deriveHdPath(rootNode, `${baseDerivationPath}/2`);
@@ -36,4 +36,4 @@ export const oraclePub = secp256k1.derivePublicKeyCompressed(oracleNode.privateK
3636
export const oraclePriv = oracleNode.privateKey;
3737
export const oracle = new PriceOracle(oracleNode.privateKey);
3838
export const oraclePkh = hash160(oraclePub);
39-
export const oracleAddress = encodeCashAddress('bchtest', 'p2pkh', oraclePkh);
39+
export const oracleAddress = encodeCashAddress('bchtest', 'p2pkhWithTokens', oraclePkh);

examples/pat_bug.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { stringify } from '@bitauth/libauth';
2+
import { compileString } from 'cashc';
3+
import {
4+
Contract,
5+
ElectrumNetworkProvider,
6+
Network,
7+
SignatureTemplate,
8+
} from 'cashscript';
9+
import { bobAddress, bobPriv } from './common.js';
10+
11+
const createContract = (network: Network = Network.REGTEST): Contract => {
12+
const script = `
13+
contract test(int nonce) {
14+
function test() {
15+
require(nonce == nonce);
16+
int minerFee = 800;
17+
int tokenValue = 1000;
18+
int sentValue = tx.inputs[1].value;
19+
int changeValue = sentValue - minerFee - tokenValue;
20+
21+
require(tx.inputs.length == 2);
22+
23+
// handle change
24+
if (changeValue < 546) {
25+
// discard dust change, whatever dust goes to miner fee
26+
// so in this case total fee = 800 + dust
27+
require(tx.outputs.length == 2);
28+
} else {
29+
// allow change output as outputs[2]
30+
require(tx.outputs.length == 3);
31+
// this forces the fee to be exactly 800
32+
require(tx.outputs[2].value == changeValue);
33+
// Require that the change output does not mint any NFTs
34+
require(tx.outputs[2].tokenCategory == 0x);
35+
// have the change address be the same as funding address
36+
bytes changeBytecode = tx.inputs[1].lockingBytecode;
37+
require(tx.outputs[2].lockingBytecode == changeBytecode);
38+
}
39+
}
40+
}
41+
`;
42+
43+
const artifact = compileString(script);
44+
const nonce = 123456n;
45+
46+
return new Contract(
47+
artifact,
48+
[nonce],
49+
{ provider: new ElectrumNetworkProvider(network) },
50+
);
51+
};
52+
53+
const minerFee = 800n;
54+
const tokenValue = 1000n;
55+
56+
const contract = createContract(Network.CHIPNET);
57+
58+
console.log('contract address', contract.tokenAddress);
59+
console.log('bob address', bobAddress);
60+
61+
const tokenCategory = 'dc92b5d83b2d2fc0b20a135664367099a0d87dcdd5e1b40504b583fdc445839b';
62+
63+
const contractUtxos = await contract.getUtxos();
64+
const mintingUtxo = contractUtxos.find((utxo) => utxo.token?.category === tokenCategory);
65+
66+
const bobUtxos = await new ElectrumNetworkProvider(Network.CHIPNET).getUtxos(bobAddress);
67+
const bobUtxo = bobUtxos.find((utxo) => utxo.satoshis >= 10000n);
68+
// console.log('bob', bobUtxo)
69+
70+
if (!mintingUtxo || !bobUtxo) process.exit(1);
71+
72+
const tx = await contract.functions.test()
73+
.from(mintingUtxo)
74+
.fromP2PKH(bobUtxo, new SignatureTemplate(bobPriv))
75+
.to([
76+
{
77+
to: contract.tokenAddress,
78+
amount: mintingUtxo.satoshis,
79+
token: {
80+
category: tokenCategory,
81+
amount: 0n,
82+
nft: {
83+
capability: 'minting',
84+
commitment: 'beef',
85+
},
86+
},
87+
},
88+
{
89+
to: bobAddress,
90+
amount: tokenValue,
91+
token: {
92+
category: tokenCategory,
93+
amount: 0n,
94+
nft: {
95+
capability: 'none',
96+
commitment: 'deadbeef',
97+
},
98+
},
99+
},
100+
{
101+
to: bobAddress,
102+
amount: bobUtxo.satoshis - tokenValue - minerFee,
103+
},
104+
])
105+
.withoutChange()
106+
// .withoutTokenChange()
107+
// .withHardcodedFee(BigInt(minerFee))
108+
.send();
109+
110+
console.log(stringify(tx));

packages/cashscript/src/Transaction.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from './utils.js';
4343
import NetworkProvider from './network/NetworkProvider.js';
4444
import SignatureTemplate from './SignatureTemplate.js';
45+
import { P2PKH_INPUT_SIZE } from './constants.js';
4546

4647
const bip68 = await import('bip68');
4748

@@ -398,7 +399,7 @@ export class Transaction {
398399
);
399400

400401
// Add one extra byte per input to over-estimate tx-in count
401-
const inputSize = getInputSize(placeholderScript) + 1;
402+
const contractInputSize = getInputSize(placeholderScript) + 1;
402403

403404
// Note that we use the addPrecision function to add "decimal points" to BigInt numbers
404405

@@ -410,7 +411,14 @@ export class Transaction {
410411
let satsAvailable = 0n;
411412
if (this.inputs.length > 0) {
412413
// If inputs are already defined, the user provided the UTXOs and we perform no further UTXO selection
413-
if (!this.hardcodedFee) fee += addPrecision(this.inputs.length * inputSize * this.feePerByte);
414+
if (!this.hardcodedFee) {
415+
const totalInputSize = this.inputs.reduce(
416+
(acc, input) => acc + (isSignableUtxo(input) ? P2PKH_INPUT_SIZE : contractInputSize),
417+
0,
418+
);
419+
fee += addPrecision(totalInputSize * this.feePerByte);
420+
}
421+
414422
satsAvailable = addPrecision(this.inputs.reduce((acc, input) => acc + input.satoshis, 0n));
415423
} else {
416424
// If inputs are not defined yet, we retrieve the contract's UTXOs and perform selection
@@ -424,14 +432,14 @@ export class Transaction {
424432
for (const utxo of automaticTokenInputs) {
425433
this.inputs.push(utxo);
426434
satsAvailable += addPrecision(utxo.satoshis);
427-
if (!this.hardcodedFee) fee += addPrecision(inputSize * this.feePerByte);
435+
if (!this.hardcodedFee) fee += addPrecision(contractInputSize * this.feePerByte);
428436
}
429437

430438
for (const utxo of bchUtxos) {
431439
if (satsAvailable > amount + fee) break;
432440
this.inputs.push(utxo);
433441
satsAvailable += addPrecision(utxo.satoshis);
434-
if (!this.hardcodedFee) fee += addPrecision(inputSize * this.feePerByte);
442+
if (!this.hardcodedFee) fee += addPrecision(contractInputSize * this.feePerByte);
435443
}
436444
}
437445

@@ -480,12 +488,9 @@ const calculateTotalTokenAmount = (outputs: Array<Output | Utxo>, tokenCategory:
480488

481489
const selectTokenUtxos = (utxos: Utxo[], amountNeeded: bigint, tokenCategory: string): Utxo[] => {
482490
const genesisUtxo = getTokenGenesisUtxo(utxos, tokenCategory);
491+
if (genesisUtxo) return [genesisUtxo];
483492

484-
if (genesisUtxo) {
485-
return [genesisUtxo];
486-
}
487-
488-
const tokenUtxos = utxos.filter((utxo) => utxo.token?.category === tokenCategory);
493+
const tokenUtxos = utxos.filter((utxo) => utxo.token?.category === tokenCategory && utxo.token?.amount > 0n);
489494

490495
// We sort the UTXOs mainly so there is consistent behaviour between network providers
491496
// even if they report UTXOs in a different order
@@ -496,9 +501,9 @@ const selectTokenUtxos = (utxos: Utxo[], amountNeeded: bigint, tokenCategory: st
496501

497502
// Add token UTXOs until we have enough to cover the amount needed (no fee calculation because it's a token)
498503
for (const utxo of tokenUtxos) {
504+
if (amountAvailable >= amountNeeded) break;
499505
selectedUtxos.push(utxo);
500506
amountAvailable += utxo.token!.amount;
501-
if (amountAvailable >= amountNeeded) break;
502507
}
503508

504509
if (amountAvailable < amountNeeded) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const VERSION_SIZE = 4;
22
export const LOCKTIME_SIZE = 4;
3+
export const P2PKH_INPUT_SIZE = 32 + 4 + 1 + 1 + 65 + 1 + 33 + 4;

yarn.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,9 +2263,9 @@
22632263
integrity sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==
22642264

22652265
"@types/node@^12.7.8":
2266-
version "12.12.54"
2267-
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.54.tgz#a4b58d8df3a4677b6c08bfbc94b7ad7a7a5f82d1"
2268-
integrity sha512-ge4xZ3vSBornVYlDnk7yZ0gK6ChHf/CHB7Gl1I0Jhah8DDnEQqBzgohYG4FX4p81TNirSETOiSyn+y1r9/IR6w==
2266+
version "12.20.55"
2267+
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"
2268+
integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==
22692269

22702270
"@types/node@^14.14.28":
22712271
version "14.14.28"

0 commit comments

Comments
 (0)