Skip to content

Commit 07cc582

Browse files
committed
fix(sdk-coin-flrp): update fee calculation and address sorting in ExportInCTxBuilder
Ticket: WIN-7770
1 parent 4ff5f62 commit 07cc582

File tree

6 files changed

+207
-46
lines changed

6 files changed

+207
-46
lines changed

modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Address,
1313
TransferOutput,
1414
OutputOwners,
15+
Signature,
1516
utils as FlareUtils,
1617
} from '@flarenetwork/flarejs';
1718
import utils from './utils';
@@ -75,7 +76,7 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
7576
return TransactionType.Export;
7677
}
7778

78-
initBuilder(tx: Tx): this {
79+
initBuilder(tx: Tx, rawBytes?: Buffer): this {
7980
const baseTx = tx as evmSerial.ExportTx;
8081
if (!this.verifyTxType(baseTx._type)) {
8182
throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type');
@@ -106,17 +107,149 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
106107
const outputAmount = transferOutput.amount();
107108
const fee = inputAmount - outputAmount;
108109
this._amount = outputAmount;
109-
this.transaction._fee.feeRate = Number(fee) - Number(this.fixedFee);
110+
// Store the actual fee directly (don't subtract fixedFee since buildFlareTransaction doesn't add it back)
111+
this.transaction._fee.feeRate = Number(fee);
110112
this.transaction._fee.fee = fee.toString();
111113
this.transaction._fee.size = 1;
112114
this.transaction._fromAddresses = [Buffer.from(input.address.toBytes())];
113115
this.transaction._locktime = transferOutput.getLocktime();
114116

115117
this._nonce = input.nonce.value();
116-
this.transaction.setTransaction(tx);
118+
119+
// Check if this is a signed transaction by looking for credentials in raw bytes
120+
const isSignedTransaction = rawBytes ? this.hasCredentialsInRawBytes(rawBytes, baseTx) : false;
121+
122+
// If it's a signed transaction, store the original raw bytes to preserve exact format
123+
if (isSignedTransaction && rawBytes) {
124+
this.transaction._rawSignedBytes = rawBytes;
125+
}
126+
127+
// Extract credentials from raw bytes if present (for signed transactions)
128+
const credentials = rawBytes ? this.extractCredentialsFromRawBytes(rawBytes, baseTx) : [];
129+
130+
// Create proper UnsignedTx wrapper with credentials
131+
const fromAddress = new Address(this.transaction._fromAddresses[0]);
132+
const addressMap = new FlareUtils.AddressMap([
133+
[fromAddress, 0],
134+
[fromAddress, 1],
135+
]);
136+
const addressMaps = new FlareUtils.AddressMaps([addressMap]);
137+
138+
const unsignedTx = new UnsignedTx(
139+
baseTx,
140+
[],
141+
addressMaps,
142+
credentials.length > 0 ? credentials : [new Credential([utils.createNewSig('')])]
143+
);
144+
145+
this.transaction.setTransaction(unsignedTx);
117146
return this;
118147
}
119148

149+
/**
150+
* Check if raw bytes contain credentials (i.e., it's a signed transaction)
151+
*/
152+
private hasCredentialsInRawBytes(rawBytes: Buffer, tx: evmSerial.ExportTx): boolean {
153+
try {
154+
const codec = FlareUtils.getManagerForVM('EVM').getDefaultCodec();
155+
const txBytes = tx.toBytes(codec);
156+
const txSize = txBytes.length;
157+
158+
// If raw bytes are longer than tx bytes, there are credentials
159+
if (rawBytes.length > txSize) {
160+
const credentialBytes = rawBytes.slice(txSize);
161+
// Check if there's at least a num_credentials field with value > 0
162+
if (credentialBytes.length >= 4) {
163+
const numCredentials = credentialBytes.readUInt32BE(0);
164+
return numCredentials > 0;
165+
}
166+
}
167+
return false;
168+
} catch (e) {
169+
return false;
170+
}
171+
}
172+
173+
/**
174+
* Extract credentials from raw transaction bytes.
175+
* Signed transactions have credentials appended after the transaction body.
176+
*
177+
* @param rawBytes - The full raw transaction bytes
178+
* @param tx - The parsed transaction (to calculate its size)
179+
* @returns Array of credentials, or empty array if none found
180+
*/
181+
private extractCredentialsFromRawBytes(rawBytes: Buffer, tx: evmSerial.ExportTx): Credential[] {
182+
try {
183+
// Get the size of the transaction without credentials using the default codec
184+
const codec = FlareUtils.getManagerForVM('EVM').getDefaultCodec();
185+
const txBytes = tx.toBytes(codec);
186+
const txSize = txBytes.length;
187+
188+
// If raw bytes are longer than tx bytes, there are credentials
189+
if (rawBytes.length <= txSize) {
190+
return [];
191+
}
192+
193+
// Extract credential bytes (everything after the transaction)
194+
const credentialBytes = rawBytes.slice(txSize);
195+
196+
// Parse credentials
197+
// Format: [num_credentials: 4 bytes] [credentials...]
198+
if (credentialBytes.length < 4) {
199+
return [];
200+
}
201+
202+
const numCredentials = credentialBytes.readUInt32BE(0);
203+
if (numCredentials === 0) {
204+
return [];
205+
}
206+
207+
const credentials: Credential[] = [];
208+
let offset = 4;
209+
210+
for (let i = 0; i < numCredentials; i++) {
211+
if (offset + 8 > credentialBytes.length) {
212+
break;
213+
}
214+
215+
// Read type ID (4 bytes) - Type ID 9 = secp256k1 credential
216+
const typeId = credentialBytes.readUInt32BE(offset);
217+
offset += 4;
218+
219+
// Validate credential type (9 = secp256k1)
220+
if (typeId !== 9) {
221+
continue; // Skip unsupported credential types
222+
}
223+
224+
// Read number of signatures (4 bytes)
225+
const numSigs = credentialBytes.readUInt32BE(offset);
226+
offset += 4;
227+
228+
// Parse all signatures for this credential
229+
const signatures: Signature[] = [];
230+
for (let j = 0; j < numSigs; j++) {
231+
if (offset + 65 > credentialBytes.length) {
232+
break;
233+
}
234+
// Each signature is 65 bytes (64 bytes signature + 1 byte recovery)
235+
const sigBytes = Buffer.from(credentialBytes.slice(offset, offset + 65));
236+
signatures.push(new Signature(sigBytes));
237+
offset += 65;
238+
}
239+
240+
// Create credential with the parsed signatures
241+
if (signatures.length > 0) {
242+
credentials.push(new Credential(signatures));
243+
}
244+
}
245+
246+
return credentials;
247+
} catch (e) {
248+
// If parsing fails, return empty credentials
249+
return [];
250+
}
251+
}
252+
120253
static verifyTxType(txnType: string): boolean {
121254
return txnType === FlareTransactionType.EvmExportTx;
122255
}
@@ -147,8 +280,9 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
147280
throw new Error('nonce is required');
148281
}
149282

150-
const txFee = BigInt(this.fixedFee);
151-
const fee = BigInt(this.transaction._fee.feeRate) + txFee;
283+
// For EVM exports, feeRate represents the total fee (baseFee * gasUnits)
284+
// Don't add fixedFee as it's already accounted for in the EVM gas model
285+
const fee = BigInt(this.transaction._fee.feeRate);
152286
this.transaction._fee.fee = fee.toString();
153287
this.transaction._fee.size = 1;
154288

@@ -158,6 +292,15 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
158292
const amount = new BigIntPr(this._amount + fee);
159293
const nonce = new BigIntPr(this._nonce);
160294
const input = new evmSerial.Input(fromAddress, amount, assetId, nonce);
295+
// Map all destination P-chain addresses for multisig support
296+
// Sort addresses alphabetically by hex representation (required by Avalanche/Flare protocol)
297+
const sortedToAddresses = [...this.transaction._to].sort((a, b) => {
298+
const aHex = Buffer.from(a).toString('hex');
299+
const bHex = Buffer.from(b).toString('hex');
300+
return aHex.localeCompare(bHex);
301+
});
302+
const toAddresses = sortedToAddresses.map((addr) => new Address(addr));
303+
161304
const exportTx = new evmSerial.ExportTx(
162305
new Int(this.transaction._networkID),
163306
utils.flareIdString(this.transaction._blockchainID),
@@ -168,9 +311,11 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
168311
assetId,
169312
new TransferOutput(
170313
new BigIntPr(this._amount),
171-
new OutputOwners(new BigIntPr(this.transaction._locktime), new Int(this.transaction._threshold), [
172-
new Address(this.transaction._to[0]),
173-
])
314+
new OutputOwners(
315+
new BigIntPr(this.transaction._locktime),
316+
new Int(this.transaction._threshold),
317+
toAddresses
318+
)
174319
)
175320
),
176321
]

modules/sdk-coin-flrp/src/lib/transaction.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function isEmptySignature(signature: string): boolean {
3232
}
3333

3434
export class Transaction extends BaseTransaction {
35-
protected _flareTransaction: pvmSerial.BaseTx | UnsignedTx;
35+
protected _flareTransaction: Tx;
3636
public _type: TransactionType;
3737
public _network: FlareNetwork;
3838
public _networkID: number;
@@ -49,11 +49,14 @@ export class Transaction extends BaseTransaction {
4949
public _rewardAddresses: Uint8Array[] = [];
5050
public _utxos: DecodedUtxoObj[] = []; // Define proper type based on Flare's UTXO structure
5151
public _fee: Partial<TransactionFee> = {};
52+
// Store original raw signed bytes to preserve exact format when re-serializing
53+
public _rawSignedBytes: Buffer | undefined;
5254

5355
constructor(coinConfig: Readonly<CoinConfig>) {
5456
super(coinConfig);
5557
this._network = coinConfig.network as FlareNetwork;
56-
this._assetId = this._network.assetId; // Update with proper Flare asset ID
58+
// Decode cb58-encoded asset ID to hex for use in transaction serialization
59+
this._assetId = utils.cb58Decode(this._network.assetId).toString('hex');
5760
this._blockchainID = this._network.blockchainID;
5861
this._networkID = this._network.networkID;
5962
}
@@ -113,6 +116,8 @@ export class Transaction extends BaseTransaction {
113116
if (emptySlotIndex !== -1) {
114117
credential.setSignature(emptySlotIndex, signature);
115118
signatureSet = true;
119+
// Clear raw signed bytes since we've modified the transaction
120+
this._rawSignedBytes = undefined;
116121
break;
117122
}
118123
}
@@ -127,9 +132,18 @@ export class Transaction extends BaseTransaction {
127132
if (!this._flareTransaction) {
128133
throw new InvalidTransactionError('Empty transaction data');
129134
}
130-
return FlareUtils.bufferToHex(
131-
FlareUtils.addChecksum((this._flareTransaction as UnsignedTx).getSignedTx().toBytes())
132-
);
135+
// If we have the original raw signed bytes, use them directly to preserve exact format
136+
if (this._rawSignedBytes) {
137+
return FlareUtils.bufferToHex(this._rawSignedBytes);
138+
}
139+
const unsignedTx = this._flareTransaction as UnsignedTx;
140+
// For signed transactions, return the full signed tx with credentials
141+
// Check signature.length for robustness
142+
if (this.signature.length > 0) {
143+
return FlareUtils.bufferToHex(unsignedTx.getSignedTx().toBytes());
144+
}
145+
// For unsigned transactions, return just the transaction bytes
146+
return FlareUtils.bufferToHex(unsignedTx.toBytes());
133147
}
134148

135149
toJson(): TxData {

modules/sdk-coin-flrp/src/lib/transactionBuilder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
7878
* @param locktime - Timestamp after which the output can be spent
7979
*/
8080
validateLocktime(locktime: bigint): void {
81-
if (!locktime || locktime < BigInt(0)) {
81+
if (locktime < BigInt(0)) {
8282
throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher');
8383
}
8484
}

modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
1818
/** @inheritdoc */
1919
from(raw: string): TransactionBuilder {
2020
utils.validateRawTransaction(raw);
21-
let transactionBuilder: TransactionBuilder | undefined = undefined;
2221
const rawNoHex = utils.removeHexPrefix(raw);
2322
const rawBuffer = Buffer.from(rawNoHex, 'hex');
2423
let txSource: 'EVM' | 'PVM';
@@ -40,9 +39,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
4039

4140
if (txSource === 'EVM') {
4241
if (ExportInCTxBuilder.verifyTxType(tx._type)) {
43-
transactionBuilder = this.getExportInCBuilder();
44-
transactionBuilder.initBuilder(tx as evmSerial.ExportTx);
45-
return transactionBuilder;
42+
const exportBuilder = this.getExportInCBuilder();
43+
exportBuilder.initBuilder(tx as evmSerial.ExportTx, rawBuffer);
44+
return exportBuilder;
4645
}
4746
}
4847
} catch (e) {
Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
// Test data for building export transactions with multiple P-addresses
22
export const EXPORT_IN_C = {
3-
txhash: 'jHRxuZjnSHYNwWpUUyob7RpfHwj1wfuQa8DGWQrkDh2RQ5Jb3',
3+
txhash: '4AiWTT1uHFw6TDekeAGxcdrfgoaif9sjRG9J6wsmkVHH7fMkL',
44
unsignedHex:
5-
'0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000000000000000000000000000000000000000000000000000000000000000000017dae940e7fbd1854207be51da222ec43f93b7d0b000000000098968000000000000000000000000000000000000000000000000000000000000000000000000000000009000000010000000000000000000000000000000000000000000000000000000000000000000000070000000000895427000000000000000a000000020000000103e1085f6e146def5a2c7bac91be5aab59710bbd0000000100000009000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000215afad8',
5+
'0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3',
66
signedHex:
7-
'0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000000000000000000000000000000000000000000000000000000000000000000017dae940e7fbd1854207be51da222ec43f93b7d0b000000000098968000000000000000000000000000000000000000000000000000000000000000000000000000000009000000010000000000000000000000000000000000000000000000000000000000000000000000070000000000895427000000000000000a000000020000000103e1085f6e146def5a2c7bac91be5aab59710bbd0000000100000009000000018ef6432440c6a0b91c7cecef787ca94742abd0759ecd98e3864992182d6b05eb6f39f08b1e032b051ff5f7893b752338bd7614a9d0b462d68bfb863680382c6c01e44d605a',
7+
'0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f300000001000000090000000133f126dee90108c473af9513ebd9eb1591a701b5dfc69041075b303b858fee0609ca9a60208b46f6836f0baf1a9fba740d97b65d45caae10470b5fa707eb45c900',
88
xPrivateKey:
99
'xprv9s21ZrQH143K2DW9jvDoAkVpRKi5V9XhZaVdoUcqoYPPQ9wRrLNT6VGgWBbRoSYB39Lak6kXgdTM9T3QokEi5n2JJ8EdggHLkZPX8eDiBu1',
1010
signature: [
11-
'0x8ef6432440c6a0b91c7cecef787ca94742abd0759ecd98e3864992182d6b05eb6f39f08b1e032b051ff5f7893b752338bd7614a9d0b462d68bfb863680382c6c01',
11+
'0x33f126dee90108c473af9513ebd9eb1591a701b5dfc69041075b303b858fee0609ca9a60208b46f6836f0baf1a9fba740d97b65d45caae10470b5fa707eb45c900',
1212
],
13-
privateKey: 'bac20595af556338287cb631060473364b023dca089c50f87efd18e70655574d',
14-
publicKey: '028fe87afe7b6a6a7f51beaf95357cb5a3cd75da16f8b24fa866d6ab8aef0dcabc',
15-
amount: '8999975',
16-
cHexAddress: '0x7Dae940e7fBd1854207Be51dA222Ec43f93b7d0b',
13+
14+
privateKey: '14977929a4e00e4af1c33545240a6a5a08ca3034214618f6b04b72b80883be3a',
15+
publicKey: '033ca1801f51484063f3bce093413ca06f7d91c44c3883f642eb103eda5e0eaed3',
16+
amount: '50000000', // 0.00005 FLR
17+
cHexAddress: '0x28A05933dC76e4e6c25f35D5c9b2A58769700E76',
1718
pAddresses: [
18-
'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq',
19-
'P-costwo1n4a86kc3td6nvmwm4xh0w78mc5jjxc9g8w6en0',
20-
'P-costwo1nhm2vw8653f3qwtj3kl6qa359kkt6y9r7qgljv',
19+
'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd',
20+
'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh',
21+
'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8',
2122
],
2223
mainAddress: 'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq',
23-
pAddressRelatedToPrivateKey: 'P-costwo1kr3937ujjftcue445uxfesz7w6247pf2a8ts4k',
24-
corethAddress: 'C-costwo1kr3937ujjftcue445uxfesz7w6247pf2a8ts4k',
24+
corethAddress: [
25+
'C-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd',
26+
'C-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh',
27+
'C-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8',
28+
],
2529
targetChainId: '11111111111111111111111111111111LpoYY',
2630
nonce: 9,
2731
threshold: 2,
28-
fee: '25',
32+
fee: '281750', // Total fee derived from expected hex (input - output = 50281750 - 50000000)
33+
locktime: 0,
2934
};

modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('ExportInCTxBuilder', function () {
9090
.nonce(testData.nonce)
9191
.amount(testData.amount)
9292
.threshold(testData.threshold)
93-
.locktime(10)
93+
.locktime(testData.locktime)
9494
.to(testData.pAddresses)
9595
.feeRate(testData.fee);
9696

@@ -110,7 +110,7 @@ describe('ExportInCTxBuilder', function () {
110110
rawTx.should.equal(testData.unsignedHex);
111111
});
112112

113-
xit('Should recover signed export from signed raw tx', async () => {
113+
it('Should recover signed export from signed raw tx', async () => {
114114
const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(testData.signedHex);
115115
const tx = await txBuilder.build();
116116
const rawTx = tx.toBroadcastFormat();
@@ -136,18 +136,16 @@ describe('ExportInCTxBuilder', function () {
136136
});
137137

138138
it('Key cannot sign the transaction', () => {
139-
it('Should full sign a export tx from unsigned raw tx', () => {
140-
const txBuilder = new TransactionBuilderFactory(coins.get('tflrp'))
141-
.from(testData.unsignedHex)
142-
.fromPubKey(testData.pAddresses);
143-
txBuilder.sign({ key: testData.privateKey });
144-
txBuilder
145-
.build()
146-
.then(() => assert.fail('it can sign'))
147-
.catch((err) => {
148-
err.message.should.be.equal('Private key cannot sign the transaction');
149-
});
150-
});
139+
const txBuilder = new TransactionBuilderFactory(coins.get('tflrp'))
140+
.from(testData.unsignedHex)
141+
.fromPubKey(testData.pAddresses);
142+
txBuilder.sign({ key: testData.privateKey });
143+
txBuilder
144+
.build()
145+
.then(() => assert.fail('it can sign'))
146+
.catch((err) => {
147+
err.message.should.be.equal('Private key cannot sign the transaction');
148+
});
151149
});
152150
});
153151
});

0 commit comments

Comments
 (0)