diff --git a/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts index 3b2bf5dfbb..51a5206315 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts @@ -75,7 +75,7 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { return TransactionType.Export; } - initBuilder(tx: Tx): this { + initBuilder(tx: Tx, rawBytes?: Buffer): this { const baseTx = tx as evmSerial.ExportTx; if (!this.verifyTxType(baseTx._type)) { throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); @@ -106,14 +106,41 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { const outputAmount = transferOutput.amount(); const fee = inputAmount - outputAmount; this._amount = outputAmount; - this.transaction._fee.feeRate = Number(fee) - Number(this.fixedFee); + // Store the actual fee directly (don't subtract fixedFee since buildFlareTransaction doesn't add it back) + this.transaction._fee.feeRate = Number(fee); this.transaction._fee.fee = fee.toString(); this.transaction._fee.size = 1; this.transaction._fromAddresses = [Buffer.from(input.address.toBytes())]; this.transaction._locktime = transferOutput.getLocktime(); this._nonce = input.nonce.value(); - this.transaction.setTransaction(tx); + + // Check if raw bytes contain credentials and extract them + const { hasCredentials, credentials } = rawBytes + ? utils.extractCredentialsFromRawBytes(rawBytes, baseTx, 'EVM') + : { hasCredentials: false, credentials: [] }; + + // If it's a signed transaction, store the original raw bytes to preserve exact format + if (hasCredentials && rawBytes) { + this.transaction._rawSignedBytes = rawBytes; + } + + // Create proper UnsignedTx wrapper with credentials + const fromAddress = new Address(this.transaction._fromAddresses[0]); + const addressMap = new FlareUtils.AddressMap([ + [fromAddress, 0], + [fromAddress, 1], + ]); + const addressMaps = new FlareUtils.AddressMaps([addressMap]); + + const unsignedTx = new UnsignedTx( + baseTx, + [], + addressMaps, + credentials.length > 0 ? credentials : [new Credential([utils.createNewSig('')])] + ); + + this.transaction.setTransaction(unsignedTx); return this; } @@ -147,8 +174,9 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { throw new Error('nonce is required'); } - const txFee = BigInt(this.fixedFee); - const fee = BigInt(this.transaction._fee.feeRate) + txFee; + // For EVM exports, feeRate represents the total fee (baseFee * gasUnits) + // Don't add fixedFee as it's already accounted for in the EVM gas model + const fee = BigInt(this.transaction._fee.feeRate); this.transaction._fee.fee = fee.toString(); this.transaction._fee.size = 1; @@ -158,6 +186,15 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { const amount = new BigIntPr(this._amount + fee); const nonce = new BigIntPr(this._nonce); const input = new evmSerial.Input(fromAddress, amount, assetId, nonce); + // Map all destination P-chain addresses for multisig support + // Sort addresses alphabetically by hex representation (required by Avalanche/Flare protocol) + const sortedToAddresses = [...this.transaction._to].sort((a, b) => { + const aHex = Buffer.from(a).toString('hex'); + const bHex = Buffer.from(b).toString('hex'); + return aHex.localeCompare(bHex); + }); + const toAddresses = sortedToAddresses.map((addr) => new Address(addr)); + const exportTx = new evmSerial.ExportTx( new Int(this.transaction._networkID), utils.flareIdString(this.transaction._blockchainID), @@ -168,9 +205,11 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { assetId, new TransferOutput( new BigIntPr(this._amount), - new OutputOwners(new BigIntPr(this.transaction._locktime), new Int(this.transaction._threshold), [ - new Address(this.transaction._to[0]), - ]) + new OutputOwners( + new BigIntPr(this.transaction._locktime), + new Int(this.transaction._threshold), + toAddresses + ) ) ), ] diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index b0145da292..0b66772cad 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -32,7 +32,7 @@ function isEmptySignature(signature: string): boolean { } export class Transaction extends BaseTransaction { - protected _flareTransaction: pvmSerial.BaseTx | UnsignedTx; + protected _flareTransaction: Tx; public _type: TransactionType; public _network: FlareNetwork; public _networkID: number; @@ -49,11 +49,14 @@ export class Transaction extends BaseTransaction { public _rewardAddresses: Uint8Array[] = []; public _utxos: DecodedUtxoObj[] = []; // Define proper type based on Flare's UTXO structure public _fee: Partial = {}; + // Store original raw signed bytes to preserve exact format when re-serializing + public _rawSignedBytes: Buffer | undefined; constructor(coinConfig: Readonly) { super(coinConfig); this._network = coinConfig.network as FlareNetwork; - this._assetId = this._network.assetId; // Update with proper Flare asset ID + // Decode cb58-encoded asset ID to hex for use in transaction serialization + this._assetId = utils.cb58Decode(this._network.assetId).toString('hex'); this._blockchainID = this._network.blockchainID; this._networkID = this._network.networkID; } @@ -113,6 +116,8 @@ export class Transaction extends BaseTransaction { if (emptySlotIndex !== -1) { credential.setSignature(emptySlotIndex, signature); signatureSet = true; + // Clear raw signed bytes since we've modified the transaction + this._rawSignedBytes = undefined; break; } } @@ -127,9 +132,18 @@ export class Transaction extends BaseTransaction { if (!this._flareTransaction) { throw new InvalidTransactionError('Empty transaction data'); } - return FlareUtils.bufferToHex( - FlareUtils.addChecksum((this._flareTransaction as UnsignedTx).getSignedTx().toBytes()) - ); + // If we have the original raw signed bytes, use them directly to preserve exact format + if (this._rawSignedBytes) { + return FlareUtils.bufferToHex(this._rawSignedBytes); + } + const unsignedTx = this._flareTransaction as UnsignedTx; + // For signed transactions, return the full signed tx with credentials + // Check signature.length for robustness + if (this.signature.length > 0) { + return FlareUtils.bufferToHex(unsignedTx.getSignedTx().toBytes()); + } + // For unsigned transactions, return just the transaction bytes + return FlareUtils.bufferToHex(unsignedTx.toBytes()); } toJson(): TxData { diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts index 9719025adc..4fc75af86c 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts @@ -78,7 +78,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { * @param locktime - Timestamp after which the output can be spent */ validateLocktime(locktime: bigint): void { - if (!locktime || locktime < BigInt(0)) { + if (locktime < BigInt(0)) { throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); } } diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index b56ffe18af..3322b1315e 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -18,7 +18,6 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { /** @inheritdoc */ from(raw: string): TransactionBuilder { utils.validateRawTransaction(raw); - let transactionBuilder: TransactionBuilder | undefined = undefined; const rawNoHex = utils.removeHexPrefix(raw); const rawBuffer = Buffer.from(rawNoHex, 'hex'); let txSource: 'EVM' | 'PVM'; @@ -40,9 +39,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { if (txSource === 'EVM') { if (ExportInCTxBuilder.verifyTxType(tx._type)) { - transactionBuilder = this.getExportInCBuilder(); - transactionBuilder.initBuilder(tx as evmSerial.ExportTx); - return transactionBuilder; + const exportBuilder = this.getExportInCBuilder(); + exportBuilder.initBuilder(tx as evmSerial.ExportTx, rawBuffer); + return exportBuilder; } } } catch (e) { diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index c5fedc7d23..9e9e238518 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -1,4 +1,12 @@ -import { Signature, TransferableOutput, TransferOutput, TypeSymbols, Id } from '@flarenetwork/flarejs'; +import { + Signature, + TransferableOutput, + TransferOutput, + TypeSymbols, + Id, + Credential, + utils as FlareUtils, +} from '@flarenetwork/flarejs'; import { BaseUtils, Entry, @@ -362,6 +370,96 @@ export class Utils implements BaseUtils { return new Id(Buffer.from(value, 'hex')); } + /** + * Extract credentials from raw transaction bytes. + * Signed transactions have credentials appended after the transaction body. + * This function handles both checking for credentials and extracting them. + * + * @param rawBytes - The full raw transaction bytes + * @param tx - The parsed transaction (must have toBytes method) + * @param vmType - The VM type ('EVM' or 'PVM') to get the correct codec + * @returns Object with hasCredentials flag and credentials array + */ + extractCredentialsFromRawBytes( + rawBytes: Buffer, + tx: { toBytes(codec: unknown): Uint8Array }, + vmType: 'EVM' | 'PVM' = 'EVM' + ): { hasCredentials: boolean; credentials: Credential[] } { + try { + // Get the size of the transaction without credentials using the default codec + const codec = FlareUtils.getManagerForVM(vmType).getDefaultCodec(); + const txBytes = tx.toBytes(codec); + const txSize = txBytes.length; + + // If raw bytes are not longer than tx bytes, there are no credentials + if (rawBytes.length <= txSize) { + return { hasCredentials: false, credentials: [] }; + } + + // Extract credential bytes (everything after the transaction) + const credentialBytes = rawBytes.slice(txSize); + + // Parse credentials + // Format: [num_credentials: 4 bytes] [credentials...] + if (credentialBytes.length < 4) { + return { hasCredentials: false, credentials: [] }; + } + + const numCredentials = credentialBytes.readUInt32BE(0); + + // Check if there are credentials in raw bytes (for hasCredentials flag) + const hasCredentials = numCredentials > 0; + + if (numCredentials === 0) { + return { hasCredentials: false, credentials: [] }; + } + + const credentials: Credential[] = []; + let offset = 4; + + for (let i = 0; i < numCredentials; i++) { + if (offset + 8 > credentialBytes.length) { + break; + } + + // Read type ID (4 bytes) - Type ID 9 = secp256k1 credential + const typeId = credentialBytes.readUInt32BE(offset); + offset += 4; + + // Validate credential type (9 = secp256k1) + if (typeId !== 9) { + continue; // Skip unsupported credential types + } + + // Read number of signatures (4 bytes) + const numSigs = credentialBytes.readUInt32BE(offset); + offset += 4; + + // Parse all signatures for this credential + const signatures: Signature[] = []; + for (let j = 0; j < numSigs; j++) { + if (offset + 65 > credentialBytes.length) { + break; + } + // Each signature is 65 bytes (64 bytes signature + 1 byte recovery) + const sigBytes = Buffer.from(credentialBytes.slice(offset, offset + 65)); + signatures.push(new Signature(sigBytes)); + offset += 65; + } + + // Create credential with the parsed signatures + if (signatures.length > 0) { + credentials.push(new Credential(signatures)); + } + } + + return { hasCredentials, credentials }; + } catch (e) { + // If parsing fails, return no credentials + return { hasCredentials: false, credentials: [] }; + } + } + /** * FlareJS wrapper to recover signature * @param network diff --git a/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts b/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts index 687afd4c3d..bf33f1db0f 100644 --- a/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts +++ b/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts @@ -1,29 +1,34 @@ // Test data for building export transactions with multiple P-addresses export const EXPORT_IN_C = { - txhash: 'jHRxuZjnSHYNwWpUUyob7RpfHwj1wfuQa8DGWQrkDh2RQ5Jb3', + txhash: 'KELMR2gmYpRUeXRyuimp1xLNUoHSkwNUURwBn4v1D4aKircKR', unsignedHex: - '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000000000000000000000000000000000000000000000000000000000000000000017dae940e7fbd1854207be51da222ec43f93b7d0b000000000098968000000000000000000000000000000000000000000000000000000000000000000000000000000009000000010000000000000000000000000000000000000000000000000000000000000000000000070000000000895427000000000000000a000000020000000103e1085f6e146def5a2c7bac91be5aab59710bbd0000000100000009000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000215afad8', + '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3', signedHex: - '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000000000000000000000000000000000000000000000000000000000000000000017dae940e7fbd1854207be51da222ec43f93b7d0b000000000098968000000000000000000000000000000000000000000000000000000000000000000000000000000009000000010000000000000000000000000000000000000000000000000000000000000000000000070000000000895427000000000000000a000000020000000103e1085f6e146def5a2c7bac91be5aab59710bbd0000000100000009000000018ef6432440c6a0b91c7cecef787ca94742abd0759ecd98e3864992182d6b05eb6f39f08b1e032b051ff5f7893b752338bd7614a9d0b462d68bfb863680382c6c01e44d605a', + '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f300000001000000090000000133f126dee90108c473af9513ebd9eb1591a701b5dfc69041075b303b858fee0609ca9a60208b46f6836f0baf1a9fba740d97b65d45caae10470b5fa707eb45c900', xPrivateKey: 'xprv9s21ZrQH143K2DW9jvDoAkVpRKi5V9XhZaVdoUcqoYPPQ9wRrLNT6VGgWBbRoSYB39Lak6kXgdTM9T3QokEi5n2JJ8EdggHLkZPX8eDiBu1', signature: [ - '0x8ef6432440c6a0b91c7cecef787ca94742abd0759ecd98e3864992182d6b05eb6f39f08b1e032b051ff5f7893b752338bd7614a9d0b462d68bfb863680382c6c01', + '0x33f126dee90108c473af9513ebd9eb1591a701b5dfc69041075b303b858fee0609ca9a60208b46f6836f0baf1a9fba740d97b65d45caae10470b5fa707eb45c900', ], - privateKey: 'bac20595af556338287cb631060473364b023dca089c50f87efd18e70655574d', - publicKey: '028fe87afe7b6a6a7f51beaf95357cb5a3cd75da16f8b24fa866d6ab8aef0dcabc', - amount: '8999975', - cHexAddress: '0x7Dae940e7fBd1854207Be51dA222Ec43f93b7d0b', + + privateKey: '14977929a4e00e4af1c33545240a6a5a08ca3034214618f6b04b72b80883be3a', + publicKey: '033ca1801f51484063f3bce093413ca06f7d91c44c3883f642eb103eda5e0eaed3', + amount: '50000000', // 0.00005 FLR + cHexAddress: '0x28A05933dC76e4e6c25f35D5c9b2A58769700E76', pAddresses: [ - 'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq', - 'P-costwo1n4a86kc3td6nvmwm4xh0w78mc5jjxc9g8w6en0', - 'P-costwo1nhm2vw8653f3qwtj3kl6qa359kkt6y9r7qgljv', + 'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', + 'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', ], mainAddress: 'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq', - pAddressRelatedToPrivateKey: 'P-costwo1kr3937ujjftcue445uxfesz7w6247pf2a8ts4k', - corethAddress: 'C-costwo1kr3937ujjftcue445uxfesz7w6247pf2a8ts4k', + corethAddress: [ + 'C-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', + 'C-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', + 'C-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', + ], targetChainId: '11111111111111111111111111111111LpoYY', nonce: 9, threshold: 2, - fee: '25', + fee: '281750', // Total fee derived from expected hex (input - output = 50281750 - 50000000) + locktime: 0, }; diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts index fd4d3950e4..8a93208faf 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts @@ -90,7 +90,7 @@ describe('ExportInCTxBuilder', function () { .nonce(testData.nonce) .amount(testData.amount) .threshold(testData.threshold) - .locktime(10) + .locktime(testData.locktime) .to(testData.pAddresses) .feeRate(testData.fee); @@ -100,6 +100,7 @@ describe('ExportInCTxBuilder', function () { const tx = await txBuilder.build(); const rawTx = tx.toBroadcastFormat(); rawTx.should.equal(testData.unsignedHex); + tx.id.should.equal(testData.txhash); }); it('Should recover export tx from raw tx', async () => { @@ -108,13 +109,15 @@ describe('ExportInCTxBuilder', function () { const tx = await txBuilder.build(); const rawTx = tx.toBroadcastFormat(); rawTx.should.equal(testData.unsignedHex); + tx.id.should.equal(testData.txhash); }); - xit('Should recover signed export from signed raw tx', async () => { + it('Should recover signed export from signed raw tx', async () => { const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(testData.signedHex); const tx = await txBuilder.build(); const rawTx = tx.toBroadcastFormat(); rawTx.should.equal(testData.signedHex); + tx.id.should.equal(testData.txhash); }); it('Should full sign a export tx for same values', async () => { @@ -125,6 +128,7 @@ describe('ExportInCTxBuilder', function () { const rawTx = tx.toBroadcastFormat(); rawTx.should.equal(testData.signedHex); tx.signature.should.eql(testData.signature); + tx.id.should.equal(testData.txhash); }); it('Should full sign a export tx from unsigned raw tx', async () => { @@ -133,21 +137,20 @@ describe('ExportInCTxBuilder', function () { const tx = await txBuilder.build(); const rawTx = tx.toBroadcastFormat(); rawTx.should.equal(testData.signedHex); + tx.id.should.equal(testData.txhash); }); it('Key cannot sign the transaction', () => { - it('Should full sign a export tx from unsigned raw tx', () => { - const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')) - .from(testData.unsignedHex) - .fromPubKey(testData.pAddresses); - txBuilder.sign({ key: testData.privateKey }); - txBuilder - .build() - .then(() => assert.fail('it can sign')) - .catch((err) => { - err.message.should.be.equal('Private key cannot sign the transaction'); - }); - }); + const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')) + .from(testData.unsignedHex) + .fromPubKey(testData.pAddresses); + txBuilder.sign({ key: testData.privateKey }); + txBuilder + .build() + .then(() => assert.fail('it can sign')) + .catch((err) => { + err.message.should.be.equal('Private key cannot sign the transaction'); + }); }); }); });