Skip to content

Commit 78e280a

Browse files
authored
Merge pull request #7648 from BitGo/fix-export-c-to-p
fix(sdk-coin-flrp): update fee calculation and address sorting in ExportInCTxBuilder
2 parents 85b25df + 32f7b43 commit 78e280a

File tree

7 files changed

+205
-47
lines changed

7 files changed

+205
-47
lines changed

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

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
7575
return TransactionType.Export;
7676
}
7777

78-
initBuilder(tx: Tx): this {
78+
initBuilder(tx: Tx, rawBytes?: Buffer): this {
7979
const baseTx = tx as evmSerial.ExportTx;
8080
if (!this.verifyTxType(baseTx._type)) {
8181
throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type');
@@ -106,14 +106,41 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
106106
const outputAmount = transferOutput.amount();
107107
const fee = inputAmount - outputAmount;
108108
this._amount = outputAmount;
109-
this.transaction._fee.feeRate = Number(fee) - Number(this.fixedFee);
109+
// Store the actual fee directly (don't subtract fixedFee since buildFlareTransaction doesn't add it back)
110+
this.transaction._fee.feeRate = Number(fee);
110111
this.transaction._fee.fee = fee.toString();
111112
this.transaction._fee.size = 1;
112113
this.transaction._fromAddresses = [Buffer.from(input.address.toBytes())];
113114
this.transaction._locktime = transferOutput.getLocktime();
114115

115116
this._nonce = input.nonce.value();
116-
this.transaction.setTransaction(tx);
117+
118+
// Check if raw bytes contain credentials and extract them
119+
const { hasCredentials, credentials } = rawBytes
120+
? utils.extractCredentialsFromRawBytes(rawBytes, baseTx, 'EVM')
121+
: { hasCredentials: false, credentials: [] };
122+
123+
// If it's a signed transaction, store the original raw bytes to preserve exact format
124+
if (hasCredentials && rawBytes) {
125+
this.transaction._rawSignedBytes = rawBytes;
126+
}
127+
128+
// Create proper UnsignedTx wrapper with credentials
129+
const fromAddress = new Address(this.transaction._fromAddresses[0]);
130+
const addressMap = new FlareUtils.AddressMap([
131+
[fromAddress, 0],
132+
[fromAddress, 1],
133+
]);
134+
const addressMaps = new FlareUtils.AddressMaps([addressMap]);
135+
136+
const unsignedTx = new UnsignedTx(
137+
baseTx,
138+
[],
139+
addressMaps,
140+
credentials.length > 0 ? credentials : [new Credential([utils.createNewSig('')])]
141+
);
142+
143+
this.transaction.setTransaction(unsignedTx);
117144
return this;
118145
}
119146

@@ -147,8 +174,9 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
147174
throw new Error('nonce is required');
148175
}
149176

150-
const txFee = BigInt(this.fixedFee);
151-
const fee = BigInt(this.transaction._fee.feeRate) + txFee;
177+
// For EVM exports, feeRate represents the total fee (baseFee * gasUnits)
178+
// Don't add fixedFee as it's already accounted for in the EVM gas model
179+
const fee = BigInt(this.transaction._fee.feeRate);
152180
this.transaction._fee.fee = fee.toString();
153181
this.transaction._fee.size = 1;
154182

@@ -158,6 +186,15 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
158186
const amount = new BigIntPr(this._amount + fee);
159187
const nonce = new BigIntPr(this._nonce);
160188
const input = new evmSerial.Input(fromAddress, amount, assetId, nonce);
189+
// Map all destination P-chain addresses for multisig support
190+
// Sort addresses alphabetically by hex representation (required by Avalanche/Flare protocol)
191+
const sortedToAddresses = [...this.transaction._to].sort((a, b) => {
192+
const aHex = Buffer.from(a).toString('hex');
193+
const bHex = Buffer.from(b).toString('hex');
194+
return aHex.localeCompare(bHex);
195+
});
196+
const toAddresses = sortedToAddresses.map((addr) => new Address(addr));
197+
161198
const exportTx = new evmSerial.ExportTx(
162199
new Int(this.transaction._networkID),
163200
utils.flareIdString(this.transaction._blockchainID),
@@ -168,9 +205,11 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder {
168205
assetId,
169206
new TransferOutput(
170207
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-
])
208+
new OutputOwners(
209+
new BigIntPr(this.transaction._locktime),
210+
new Int(this.transaction._threshold),
211+
toAddresses
212+
)
174213
)
175214
),
176215
]

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) {

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

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Signature, TransferableOutput, TransferOutput, TypeSymbols, Id } from '@flarenetwork/flarejs';
1+
import {
2+
Signature,
3+
TransferableOutput,
4+
TransferOutput,
5+
TypeSymbols,
6+
Id,
7+
Credential,
8+
utils as FlareUtils,
9+
} from '@flarenetwork/flarejs';
210
import {
311
BaseUtils,
412
Entry,
@@ -362,6 +370,96 @@ export class Utils implements BaseUtils {
362370
return new Id(Buffer.from(value, 'hex'));
363371
}
364372

373+
/**
374+
* Extract credentials from raw transaction bytes.
375+
* Signed transactions have credentials appended after the transaction body.
376+
* This function handles both checking for credentials and extracting them.
377+
*
378+
* @param rawBytes - The full raw transaction bytes
379+
* @param tx - The parsed transaction (must have toBytes method)
380+
* @param vmType - The VM type ('EVM' or 'PVM') to get the correct codec
381+
* @returns Object with hasCredentials flag and credentials array
382+
*/
383+
extractCredentialsFromRawBytes(
384+
rawBytes: Buffer,
385+
tx: { toBytes(codec: unknown): Uint8Array },
386+
vmType: 'EVM' | 'PVM' = 'EVM'
387+
): { hasCredentials: boolean; credentials: Credential[] } {
388+
try {
389+
// Get the size of the transaction without credentials using the default codec
390+
const codec = FlareUtils.getManagerForVM(vmType).getDefaultCodec();
391+
const txBytes = tx.toBytes(codec);
392+
const txSize = txBytes.length;
393+
394+
// If raw bytes are not longer than tx bytes, there are no credentials
395+
if (rawBytes.length <= txSize) {
396+
return { hasCredentials: false, credentials: [] };
397+
}
398+
399+
// Extract credential bytes (everything after the transaction)
400+
const credentialBytes = rawBytes.slice(txSize);
401+
402+
// Parse credentials
403+
// Format: [num_credentials: 4 bytes] [credentials...]
404+
if (credentialBytes.length < 4) {
405+
return { hasCredentials: false, credentials: [] };
406+
}
407+
408+
const numCredentials = credentialBytes.readUInt32BE(0);
409+
410+
// Check if there are credentials in raw bytes (for hasCredentials flag)
411+
const hasCredentials = numCredentials > 0;
412+
413+
if (numCredentials === 0) {
414+
return { hasCredentials: false, credentials: [] };
415+
}
416+
417+
const credentials: Credential[] = [];
418+
let offset = 4;
419+
420+
for (let i = 0; i < numCredentials; i++) {
421+
if (offset + 8 > credentialBytes.length) {
422+
break;
423+
}
424+
425+
// Read type ID (4 bytes) - Type ID 9 = secp256k1 credential
426+
const typeId = credentialBytes.readUInt32BE(offset);
427+
offset += 4;
428+
429+
// Validate credential type (9 = secp256k1)
430+
if (typeId !== 9) {
431+
continue; // Skip unsupported credential types
432+
}
433+
434+
// Read number of signatures (4 bytes)
435+
const numSigs = credentialBytes.readUInt32BE(offset);
436+
offset += 4;
437+
438+
// Parse all signatures for this credential
439+
const signatures: Signature[] = [];
440+
for (let j = 0; j < numSigs; j++) {
441+
if (offset + 65 > credentialBytes.length) {
442+
break;
443+
}
444+
// Each signature is 65 bytes (64 bytes signature + 1 byte recovery)
445+
const sigBytes = Buffer.from(credentialBytes.slice(offset, offset + 65));
446+
signatures.push(new Signature(sigBytes));
447+
offset += 65;
448+
}
449+
450+
// Create credential with the parsed signatures
451+
if (signatures.length > 0) {
452+
credentials.push(new Credential(signatures));
453+
}
454+
}
455+
456+
return { hasCredentials, credentials };
457+
} catch (e) {
458+
// If parsing fails, return no credentials
459+
return { hasCredentials: false, credentials: [] };
460+
}
461+
}
462+
365463
/**
366464
* FlareJS wrapper to recover signature
367465
* @param network
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: 'KELMR2gmYpRUeXRyuimp1xLNUoHSkwNUURwBn4v1D4aKircKR',
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
};

0 commit comments

Comments
 (0)