Skip to content

Commit 194442c

Browse files
ahsan-javaidjanniks
authored andcommitted
fix: support length estimation in fee estimate interface
1 parent 701416a commit 194442c

File tree

2 files changed

+159
-5
lines changed

2 files changed

+159
-5
lines changed

packages/transactions/src/builders.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createSponsoredAuth,
99
createStandardAuth,
1010
SpendingCondition,
11+
MultiSigSpendingCondition,
1112
} from './authorization';
1213
import { ClarityValue, PrincipalCV } from './clarity';
1314
import {
@@ -21,6 +22,8 @@ import {
2122
SingleSigHashMode,
2223
TransactionVersion,
2324
TxRejectedReason,
25+
RECOVERABLE_ECDSA_SIG_LENGTH_BYTES,
26+
StacksMessageType,
2427
} from './constants';
2528
import { ClarityAbi, validateContractCall } from './contract-abi';
2629
import {
@@ -615,7 +618,7 @@ export async function makeUnsignedSTXTokenTransfer(
615618
);
616619

617620
if (txOptions.fee === undefined || txOptions.fee === null) {
618-
const estimatedLen = transaction.serialize().byteLength;
621+
const estimatedLen = estimateTransactionByteLength(transaction);
619622
const txFee = await estimateTransaction(payload, estimatedLen, options.network);
620623
transaction.setFee(txFee[1].fee);
621624
}
@@ -841,7 +844,7 @@ export async function makeUnsignedContractDeploy(
841844
);
842845

843846
if (txOptions.fee === undefined || txOptions.fee === null) {
844-
const estimatedLen = transaction.serialize().byteLength;
847+
const estimatedLen = estimateTransactionByteLength(transaction);
845848
const txFee = await estimateTransaction(payload, estimatedLen, options.network);
846849
transaction.setFee(txFee[1].fee);
847850
}
@@ -1049,7 +1052,7 @@ export async function makeUnsignedContractCall(
10491052
);
10501053

10511054
if (txOptions.fee === undefined || txOptions.fee === null) {
1052-
const estimatedLen = transaction.serialize().byteLength;
1055+
const estimatedLen = estimateTransactionByteLength(transaction);
10531056
const txFee = await estimateTransaction(payload, estimatedLen, network);
10541057
transaction.setFee(txFee[1].fee);
10551058
}
@@ -1376,7 +1379,7 @@ export async function sponsorTransaction(
13761379
case PayloadType.TokenTransfer:
13771380
case PayloadType.SmartContract:
13781381
case PayloadType.ContractCall:
1379-
const estimatedLen = options.transaction.serialize().byteLength;
1382+
const estimatedLen = estimateTransactionByteLength(options.transaction);
13801383
try {
13811384
txFee = (await estimateTransaction(options.transaction.payload, estimatedLen, network))[1]
13821385
.fee;
@@ -1424,3 +1427,41 @@ export async function sponsorTransaction(
14241427

14251428
return signer.transaction;
14261429
}
1430+
1431+
/**
1432+
* Estimates transaction byte length
1433+
* Context:
1434+
* 1) Multi-sig transaction byte length increases by adding signatures
1435+
* which causes the incorrect fee estimation because the fee value is set while creating unsigned transaction
1436+
* 2) Single-sig transaction byte length remain same due to empty message signature which allocates the space for signature
1437+
* @param {transaction} - StacksTransaction object to be estimated
1438+
* @return {number} Estimated transaction byte length
1439+
*/
1440+
export function estimateTransactionByteLength(transaction: StacksTransaction): number {
1441+
const hashMode = transaction.auth.spendingCondition.hashMode;
1442+
// List of Multi-sig transaction hash modes
1443+
const multiSigHashModes = [AddressHashMode.SerializeP2SH, AddressHashMode.SerializeP2WSH];
1444+
1445+
// Check if its a Multi-sig transaction
1446+
if (multiSigHashModes.includes(hashMode)) {
1447+
const multiSigSpendingCondition: MultiSigSpendingCondition = transaction.auth
1448+
.spendingCondition as MultiSigSpendingCondition;
1449+
1450+
// Find number of existing signatures if the transaction is signed or partially signed
1451+
const existingSignatures = multiSigSpendingCondition.fields.filter(
1452+
field => field.contents.type === StacksMessageType.MessageSignature
1453+
).length; // existingSignatures will be 0 if its a unsigned transaction
1454+
1455+
// Estimate total signature bytes size required for this multi-sig transaction
1456+
// Formula: totalSignatureLength = (signaturesRequired - existingSignatures) * (SIG_LEN_BYTES + 1 byte of type of signature)
1457+
const totalSignatureLength =
1458+
(multiSigSpendingCondition.signaturesRequired - existingSignatures) *
1459+
(RECOVERABLE_ECDSA_SIG_LENGTH_BYTES + 1);
1460+
1461+
return transaction.serialize().byteLength + totalSignatureLength;
1462+
} else {
1463+
// Single-sig transaction
1464+
// Signature space already allocated by empty message signature
1465+
return transaction.serialize().byteLength;
1466+
}
1467+
}

packages/transactions/tests/builder.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
makeStandardFungiblePostCondition, makeStandardNonFungiblePostCondition, makeStandardSTXPostCondition, makeSTXTokenTransfer,
1818
makeUnsignedContractCall, makeUnsignedContractDeploy, makeUnsignedSTXTokenTransfer, SignedTokenTransferOptions, sponsorTransaction, TxBroadcastResult,
1919
TxBroadcastResultOk,
20-
TxBroadcastResultRejected
20+
TxBroadcastResultRejected,
21+
estimateTransactionByteLength
2122
} from '../src/builders';
2223
import { bufferCV, bufferCVFromString, serializeCV, standardPrincipalCV } from '../src/clarity';
2324
import { createMessageSignature } from '../src/common';
@@ -1061,6 +1062,118 @@ test('Estimate transaction transfer fee', async () => {
10611062
expect(resultEstimateFee2.map(f => f.fee)).toEqual([140, 17, 125]);
10621063
});
10631064

1065+
test('Single-sig transaction byte length must include signature', async () => {
1066+
/*
1067+
* *** Context ***
1068+
* 1) Single-sig transaction byte length remain same due to empty message signature which allocates the space for signature
1069+
* 2) estimateTransactionByteLength should correctly estimate the byte length of single-sig transaction
1070+
*/
1071+
1072+
const recipient = standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159');
1073+
const amount = 2500000;
1074+
const fee = 100;
1075+
const nonce = 10;
1076+
const memo = 'test memo...';
1077+
1078+
const privateKey = 'a5c61c6ca7b3e7e55edee68566aeab22e4da26baa285c7bd10e8d2218aa3b229';
1079+
const publicKey = '027d28f9951ce46538951e3697c62588a87f1f1f295de4a14fdd4c780fc52cfe69';
1080+
1081+
// Create a unsigned single-sig transaction
1082+
const unsignedTransaction = await makeUnsignedSTXTokenTransfer({
1083+
recipient,
1084+
amount,
1085+
fee,
1086+
nonce,
1087+
memo: memo,
1088+
publicKey: publicKey,
1089+
anchorMode: AnchorMode.Any
1090+
});
1091+
1092+
// Due to empty message signature space will be allocated for signature
1093+
expect(unsignedTransaction.serialize().byteLength).toEqual(estimateTransactionByteLength(unsignedTransaction));
1094+
1095+
const signer = new TransactionSigner(unsignedTransaction);
1096+
// Now sign the transaction and verify the byteLength after adding signature
1097+
signer.signOrigin(createStacksPrivateKey(privateKey));
1098+
1099+
const finalSerializedTx = signer.transaction.serialize();
1100+
1101+
// Byte length will remains the same after signing due to pre allocated space by empty message signature
1102+
expect(finalSerializedTx.byteLength).toEqual(estimateTransactionByteLength(signer.transaction));
1103+
});
1104+
1105+
test('Multi-sig transaction byte length must include the required signatures', async () => {
1106+
/*
1107+
* *** Context ***
1108+
* 1) Multi-sig transaction byte length increases by adding signatures
1109+
* which causes the incorrect fee estimation because the fee value is set while creating unsigned transaction
1110+
* 2) estimateTransactionByteLength should correctly estimate the byte length of multi-sig transaction
1111+
*/
1112+
1113+
const recipient = standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159');
1114+
const amount = 2500000;
1115+
const fee = 100;
1116+
const nonce = 10;
1117+
const memo = 'test memo...';
1118+
1119+
const privKeyStrings = [
1120+
'6d430bb91222408e7706c9001cfaeb91b08c2be6d5ac95779ab52c6b431950e001',
1121+
'2a584d899fed1d24e26b524f202763c8ab30260167429f157f1c119f550fa6af01',
1122+
'd5200dee706ee53ae98a03fba6cf4fdcc5084c30cfa9e1b3462dcdeaa3e0f1d201',
1123+
];
1124+
const privKeys = privKeyStrings.map(createStacksPrivateKey);
1125+
1126+
const pubKeys = privKeyStrings.map(pubKeyfromPrivKey);
1127+
const pubKeyStrings = pubKeys.map(publicKeyToString);
1128+
1129+
// Create a unsigned multi-sig transaction
1130+
const transaction = await makeUnsignedSTXTokenTransfer({
1131+
recipient,
1132+
amount,
1133+
fee,
1134+
nonce,
1135+
memo: memo,
1136+
numSignatures: 3,
1137+
publicKeys: pubKeyStrings,
1138+
anchorMode: AnchorMode.Any
1139+
});
1140+
1141+
// Total length without signatures
1142+
const unsignedByteLength = 120;
1143+
1144+
const serializedTx = transaction.serialize();
1145+
// Unsigned transaction byte length without signatures
1146+
expect(serializedTx.byteLength).toEqual(unsignedByteLength);
1147+
1148+
// Expected final byte length after adding the required signatures
1149+
const expectedFinalLength = 318;
1150+
1151+
// estimatedLen includes the space required for signatures in case of multi-sig transaction
1152+
expect(estimateTransactionByteLength(transaction)).toEqual(expectedFinalLength);
1153+
1154+
// Now add the required signatures one by one and test it against the value returned by estimateTransactionByteLength
1155+
const signer = new TransactionSigner(transaction);
1156+
1157+
signer.signOrigin(privKeys[0]);
1158+
// Should calculate correct length if transaction is partially signed
1159+
expect(estimateTransactionByteLength(signer.transaction)).toEqual(expectedFinalLength);
1160+
1161+
signer.signOrigin(privKeys[1]);
1162+
// Should calculate correct length if transaction is partially signed
1163+
expect(estimateTransactionByteLength(signer.transaction)).toEqual(expectedFinalLength);
1164+
1165+
signer.signOrigin(privKeys[2]);
1166+
// Should calculate correct length if transaction is completely signed
1167+
expect(estimateTransactionByteLength(signer.transaction)).toEqual(expectedFinalLength);
1168+
1169+
const finalSerializedTx = signer.transaction.serialize();
1170+
// Validate expectedFinalLength is correct
1171+
expect(finalSerializedTx.byteLength).toEqual(expectedFinalLength);
1172+
1173+
// Final byte length should match as estimated by estimateTransactionByteLength
1174+
expect(finalSerializedTx.byteLength).toEqual(estimateTransactionByteLength(signer.transaction));
1175+
});
1176+
10641177
test('Make STX token transfer with fetch account nonce', async () => {
10651178
const nonce = 123;
10661179
const recipient = standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159');

0 commit comments

Comments
 (0)