diff --git a/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts index 51a5206315..bbec4940a3 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, rawBytes?: Buffer): this { + initBuilder(tx: Tx, rawBytes?: Buffer, parsedCredentials?: Credential[]): 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'); @@ -115,10 +115,9 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { this._nonce = input.nonce.value(); - // Check if raw bytes contain credentials and extract them - const { hasCredentials, credentials } = rawBytes - ? utils.extractCredentialsFromRawBytes(rawBytes, baseTx, 'EVM') - : { hasCredentials: false, credentials: [] }; + // Use credentials passed from TransactionBuilderFactory (properly extracted using codec) + const credentials = parsedCredentials || []; + const hasCredentials = credentials.length > 0; // If it's a signed transaction, store the original raw bytes to preserve exact format if (hasCredentials && rawBytes) { diff --git a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts index d61afde8ec..3c0c72dda9 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts @@ -51,7 +51,7 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { return this; } - initBuilder(tx: Tx, rawBytes?: Buffer): this { + initBuilder(tx: Tx, rawBytes?: Buffer, parsedCredentials?: Credential[]): this { const exportTx = tx as pvmSerial.ExportTx; if (!this.verifyTxType(exportTx._type)) { @@ -101,37 +101,11 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { const fee = totalInputAmount - changeOutputAmount - this._amount; this.transaction._fee.fee = fee.toString(); - // Extract credentials from raw bytes - let hasCredentials = false; - let credentials: Credential[] = []; - - if (rawBytes) { - // Try standard extraction first - const result = utils.extractCredentialsFromRawBytes(rawBytes, exportTx, 'PVM'); - hasCredentials = result.hasCredentials; - credentials = result.credentials; - - // If extraction failed but raw bytes are longer, try parsing credentials at known offset - if ((!hasCredentials || credentials.length === 0) && rawBytes.length > 300) { - const codec = FlareUtils.getManagerForVM('PVM').getDefaultCodec(); - const txBytesLength = exportTx.toBytes(codec).length; - - if (rawBytes.length > txBytesLength) { - hasCredentials = true; - const credResult = utils.parseCredentialsAtOffset(rawBytes, txBytesLength); - if (credResult.length > 0) { - credentials = credResult; - } - } - } - } + // Use credentials passed from TransactionBuilderFactory (properly extracted using codec) + const credentials = parsedCredentials || []; + const hasCredentials = credentials.length > 0; - // If we have parsed credentials with the correct number of credentials for the inputs, - // use them directly (preserves existing signatures) - const numInputs = exportTx.baseTx.inputs.length; - const useDirectCredentials = hasCredentials && credentials.length === numInputs; - - // If there are credentials in raw bytes, store the original bytes to preserve exact format + // If there are credentials, store the original bytes to preserve exact format if (rawBytes && hasCredentials) { this.transaction._rawSignedBytes = rawBytes; } @@ -139,57 +113,22 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { // Create proper UnsignedTx wrapper with credentials const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b)); - // Helper function to check if a signature is empty (contains no real signature data) - // A real ECDSA signature will never start with 45 bytes of zeros - const isSignatureEmpty = (sig: string): boolean => { - if (!sig) return true; - const cleanSig = utils.removeHexPrefix(sig); - if (cleanSig.length === 0) return true; - // Check if the first 90 hex chars (45 bytes) are all zeros - // Real signatures from secp256k1 will never have this pattern - const first90Chars = cleanSig.substring(0, 90); - return first90Chars === '0'.repeat(90) || first90Chars === '0'.repeat(first90Chars.length); - }; - - // Build txCredentials - either use direct credentials or reconstruct with embedded addresses - let txCredentials: Credential[]; - - if (useDirectCredentials) { - // Use the extracted credentials directly - they already have the correct signatures - // Just ensure empty slots have embedded addresses for signing identification - txCredentials = credentials; - } else { - // Reconstruct credentials from scratch with embedded addresses - txCredentials = exportTx.baseTx.inputs.map((input, idx) => { - const transferInput = input.input as TransferInput; - const inputThreshold = transferInput.sigIndicies().length || this.transaction._threshold; - - // Get existing signatures from parsed credentials if available - const existingSigs: string[] = []; - if (idx < credentials.length) { - const existingCred = credentials[idx]; - existingSigs.push(...existingCred.getSignatures()); - } - - // Create credential with correct number of slots, preserving existing signatures - // Empty slots get embedded addresses for slot identification - const sigSlots: ReturnType[] = []; - for (let i = 0; i < inputThreshold; i++) { - const existingSig = i < existingSigs.length ? existingSigs[i] : null; - - if (existingSig && !isSignatureEmpty(existingSig)) { - // Use existing non-empty signature (real signature from signing) - const sigHex = utils.removeHexPrefix(existingSig); - sigSlots.push(utils.createNewSig(sigHex)); - } else { - // Empty slot - create with embedded address for slot identification - const addrHex = Buffer.from(sortedAddresses[i]).toString('hex'); - sigSlots.push(utils.createEmptySigWithAddress(addrHex)); - } - } - return new Credential(sigSlots); - }); - } + // When credentials were extracted, use them directly to preserve existing signatures + // Otherwise, create empty credentials with embedded addresses for slot identification + const txCredentials = + credentials.length > 0 + ? credentials + : exportTx.baseTx.inputs.map((input) => { + const transferInput = input.input as TransferInput; + const inputThreshold = transferInput.sigIndicies().length || this.transaction._threshold; + // Create empty signatures with embedded addresses for slot identification + const sigSlots: ReturnType[] = []; + for (let i = 0; i < inputThreshold; i++) { + const addrHex = Buffer.from(sortedAddresses[i]).toString('hex'); + sigSlots.push(utils.createEmptySigWithAddress(addrHex)); + } + return new Credential(sigSlots); + }); // Create address maps for signing - one per input/credential // Each address map contains all addresses mapped to their indices diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index a73596be95..800ebd4758 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -9,7 +9,6 @@ import { Int, Id, TransferableInput, - TypeSymbols, Address, utils as FlareUtils, avmSerial, @@ -36,7 +35,7 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { return TransactionType.Import; } - initBuilder(tx: Tx): this { + initBuilder(tx: Tx, rawBytes?: Buffer, parsedCredentials?: Credential[]): this { const baseTx = tx as evmSerial.ImportTx; if (!this.verifyTxType(baseTx._type)) { throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); @@ -50,8 +49,7 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { } const output = outputs[0]; - const assetIdStr = Buffer.from(this.transaction._assetId).toString('hex'); - if (Buffer.from(output.assetId.toBytes()).toString('hex') !== assetIdStr) { + if (Buffer.from(output.assetId.toBytes()).toString('hex') !== this.transaction._assetId) { throw new Error('AssetID are not equals'); } this.transaction._to = [Buffer.from(output.address.toBytes())]; @@ -66,7 +64,8 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { // Calculate fee based on input/output difference const fee = totalInputAmount - totalOutputAmount; const feeSize = this.calculateFeeSize(baseTx); - const feeRate = Number(fee) / feeSize; + // Use integer division to ensure feeRate can be converted back to BigInt + const feeRate = Math.floor(Number(fee) / feeSize); this.transaction._fee = { fee: fee.toString(), @@ -74,12 +73,46 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { size: feeSize, }; - this.transaction.setTransaction(tx); + // Use credentials passed from TransactionBuilderFactory (properly extracted using codec) + const credentials = parsedCredentials || []; + const hasCredentials = credentials.length > 0; + + // If it's a signed transaction, store the original raw bytes to preserve exact format + if (hasCredentials && rawBytes) { + this.transaction._rawSignedBytes = rawBytes; + } + + // Extract threshold from first input's sigIndicies (number of required signatures) + const firstInput = inputs[0]; + const inputThreshold = firstInput.sigIndicies().length || this.transaction._threshold; + this.transaction._threshold = inputThreshold; + + // Create proper UnsignedTx wrapper with credentials + const toAddress = new Address(output.address.toBytes()); + const addressMap = new FlareUtils.AddressMap([[toAddress, 0]]); + const addressMaps = new FlareUtils.AddressMaps([addressMap]); + + // When credentials were extracted, use them directly to preserve existing signatures + let txCredentials: Credential[]; + if (credentials.length > 0) { + txCredentials = credentials; + } else { + // Create empty credential with threshold number of signature slots + const emptySignatures: ReturnType[] = []; + for (let i = 0; i < inputThreshold; i++) { + emptySignatures.push(utils.createNewSig('')); + } + txCredentials = [new Credential(emptySignatures)]; + } + + const unsignedTx = new UnsignedTx(baseTx, [], addressMaps, txCredentials); + + this.transaction.setTransaction(unsignedTx); return this; } static verifyTxType(txnType: string): boolean { - return txnType === FlareTransactionType.PvmImportTx; + return txnType === FlareTransactionType.EvmImportTx; } verifyTxType(txnType: string): boolean { @@ -91,8 +124,10 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { * @protected */ protected buildFlareTransaction(): void { - // if tx has credentials, tx shouldn't change + // if tx has credentials or was already recovered from raw, tx shouldn't change if (this.transaction.hasCredentials) return; + // If fee is already calculated (from initBuilder), the transaction is already built + if (this.transaction._fee.fee) return; if (this.transaction._to.length !== 1) { throw new Error('to is required'); } @@ -109,14 +144,12 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { this.transaction._fee.fee = fee.toString(); this.transaction._fee.size = feeSize; - // Create output with required interface implementation - const output = { - _type: TypeSymbols.BaseTx, - address: new Address(this.transaction._to[0]), - amount: new BigIntPr(amount - fee), - assetId: new Id(new Uint8Array(Buffer.from(this.transaction._assetId, 'hex'))), - toBytes: () => new Uint8Array(), - }; + // Create EVM output using proper FlareJS class + const output = new evmSerial.Output( + new Address(this.transaction._to[0]), + new BigIntPr(amount - fee), + new Id(new Uint8Array(Buffer.from(this.transaction._assetId, 'hex'))) + ); // Create the import transaction const importTx = new evmSerial.ImportTx( @@ -127,8 +160,11 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { [output] ); - // Create unsigned transaction - const addressMap = new FlareUtils.AddressMap([[new Address(this.transaction._fromAddresses[0]), 0]]); + // Create unsigned transaction with all potential signers in address map + const addressMap = new FlareUtils.AddressMap(); + this.transaction._fromAddresses.forEach((addr, i) => { + addressMap.set(new Address(addr), i); + }); const addressMaps = new FlareUtils.AddressMaps([addressMap]); const unsignedTx = new UnsignedTx( @@ -172,49 +208,29 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { const amount = BigInt(utxo.amount); totalAmount += amount; - // Create input with proper interface implementation - const input = { - _type: TypeSymbols.Input, - amount: () => amount, - sigIndices: sender.map((_, i) => i), - toBytes: () => new Uint8Array(), - }; - - // Create TransferableInput with proper UTXOID implementation - const txId = new Id(new Uint8Array(Buffer.from(utxo.txid, 'hex'))); - const outputIdxInt = new Int(Number(utxo.outputidx)); - const outputIdxBytes = new Uint8Array(Buffer.alloc(4)); - new DataView(outputIdxBytes.buffer).setInt32(0, Number(utxo.outputidx), true); - const outputIdxId = new Id(outputIdxBytes); - - // Create asset with complete Amounter interface - const assetIdBytes = new Uint8Array(Buffer.from(this.transaction._assetId, 'hex')); - const assetId = { - _type: TypeSymbols.BaseTx, - amount: () => amount, - toBytes: () => assetIdBytes, - toString: () => Buffer.from(assetIdBytes).toString('hex'), - }; + // Create signature indices for threshold + const sigIndices: number[] = []; + for (let i = 0; i < this.transaction._threshold; i++) { + sigIndices.push(i); + } - // Create TransferableInput with UTXOID using Int for outputIdx - const transferableInput = new TransferableInput( - { - _type: TypeSymbols.UTXOID, - txID: txId, - outputIdx: outputIdxInt, - ID: () => utxo.txid, - toBytes: () => new Uint8Array(), - }, - outputIdxId, // Use Id type for TransferableInput constructor - assetId // Use asset with complete Amounter interface + // Use fromNative to create TransferableInput (same pattern as ImportInPTxBuilder) + // fromNative expects cb58-encoded strings for txId and assetId + const txIdCb58 = utxo.txid; // Already cb58 encoded + const assetIdCb58 = utils.cb58Encode(Buffer.from(this.transaction._assetId, 'hex')); + + const transferableInput = TransferableInput.fromNative( + txIdCb58, + Number(utxo.outputidx), + assetIdCb58, + amount, + sigIndices ); - // Set input properties - Object.assign(transferableInput, { input }); inputs.push(transferableInput); - // Create empty credential for each input - const emptySignatures = sender.map(() => utils.createNewSig('')); + // Create empty credential for each input with threshold signers + const emptySignatures = sigIndices.map(() => utils.createNewSig('')); const credential = new Credential(emptySignatures); credentials.push(credential); }); @@ -228,6 +244,7 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { /** * Calculate the fee size for the transaction + * For C-chain imports, the feeRate is treated as an absolute fee value */ private calculateFeeSize(tx?: evmSerial.ImportTx): number { // If tx is provided, calculate based on actual transaction size @@ -236,14 +253,8 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { return tx.toBytes(codec).length; } - // Otherwise estimate based on typical import transaction size - const baseSize = 256; // Base transaction size - const inputSize = 128; // Size per input - const outputSize = 64; // Size per output - const numInputs = this.transaction._utxos.length; - const numOutputs = 1; // Import tx always has 1 output - - return baseSize + inputSize * numInputs + outputSize * numOutputs; + // For C-chain imports, treat feeRate as the absolute fee (multiplier of 1) + return 1; } /** diff --git a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts index 96aa54c3f9..6ebd169071 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts @@ -38,7 +38,7 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { return TransactionType.Import; } - initBuilder(tx: Tx, rawBytes?: Buffer): this { + initBuilder(tx: Tx, rawBytes?: Buffer, parsedCredentials?: Credential[]): this { const importTx = tx as pvmSerial.ImportTx; if (!this.verifyTxType(importTx._type)) { @@ -82,30 +82,11 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { const fee = totalInputAmount - outputAmount; this.transaction._fee.fee = fee.toString(); - // Check if raw bytes contain credentials - // For PVM transactions, credentials start after the unsigned tx bytes - let hasCredentials = false; - let credentials: Credential[] = []; - - if (rawBytes) { - // Try standard extraction first - const result = utils.extractCredentialsFromRawBytes(rawBytes, importTx, 'PVM'); - hasCredentials = result.hasCredentials; - credentials = result.credentials; - - // If extraction failed but raw bytes are longer, try parsing credentials at known offset - // For ImportTx, the unsigned tx is typically 302 bytes - if ((!hasCredentials || credentials.length === 0) && rawBytes.length > 350) { - hasCredentials = true; - // Try to extract credentials at the standard position (302 bytes) - const credResult = utils.parseCredentialsAtOffset(rawBytes, 302); - if (credResult.length > 0) { - credentials = credResult; - } - } - } + // Use credentials passed from TransactionBuilderFactory (properly extracted using codec) + const credentials = parsedCredentials || []; + const hasCredentials = credentials.length > 0; - // If there are credentials in raw bytes, store the original bytes to preserve exact format + // If there are credentials, store the original bytes to preserve exact format if (rawBytes && hasCredentials) { this.transaction._rawSignedBytes = rawBytes; } @@ -114,7 +95,7 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b)); const addressMaps = sortedAddresses.map((a, i) => new FlareUtils.AddressMap([[new Address(a), i]])); - // Create credentials if none exist + // When credentials were extracted, use them directly to preserve existing signatures const txCredentials = credentials.length > 0 ? credentials diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 615d32a187..04098649bd 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -168,10 +168,10 @@ export class Transaction extends BaseTransaction { ); }); - if (hasMatchingAddress) { - const signature = await secp256k1.sign(unsignedBytes, prv); + const signature = await secp256k1.sign(unsignedBytes, prv); + let signatureSet = false; - let signatureSet = false; + if (hasMatchingAddress) { // Use address-based slot matching (like AVAX-P) let checkSign: CheckSignature | undefined = undefined; @@ -196,11 +196,29 @@ export class Transaction extends BaseTransaction { if (signatureSet) break; } + } - if (!signatureSet) { - throw new SigningError('No matching signature slot found for this private key'); + // Fallback: If address-based matching didn't work (e.g., ImportInC loaded from unsigned tx + // where P-chain addresses aren't in addressMaps), try to sign the first empty slot. + // This handles the case where we have empty credentials but signer address isn't in the map. + if (!signatureSet) { + for (const credential of unsignedTx.credentials) { + const signatures = credential.getSignatures(); + for (let i = 0; i < signatures.length; i++) { + if (isEmptySignature(signatures[i])) { + credential.setSignature(i, signature); + signatureSet = true; + this._rawSignedBytes = undefined; + break; + } + } + if (signatureSet) break; } } + + if (!signatureSet) { + throw new SigningError('No matching signature slot found for this private key'); + } } toBroadcastFormat(): string { diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index 0d5686d380..86dd56b6e1 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -1,4 +1,4 @@ -import { utils as FlareUtils, evmSerial, pvmSerial } from '@flarenetwork/flarejs'; +import { utils as FlareUtils, evmSerial, pvmSerial, Credential } from '@flarenetwork/flarejs'; import { BaseTransactionBuilderFactory, NotSupported } from '@bitgo/sdk-core'; import { FlareNetwork, BaseCoin as CoinConfig } from '@bitgo/statics'; import { TransactionBuilder } from './transactionBuilder'; @@ -15,6 +15,37 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { super(_coinConfig); } + /** + * Extract credentials from remaining bytes after transaction using FlareJS codec. + * This is the proper way to parse credentials - using the codec's UnpackPrefix method. + * @param credentialBytes Remaining bytes after the transaction (starts with numCredentials) + * @param codec The FlareJS codec to use for unpacking + * @returns Array of parsed credentials + */ + private extractCredentialsWithCodec(credentialBytes: Uint8Array, codec: any): Credential[] { + const credentials: Credential[] = []; + if (credentialBytes.length < 4) { + return credentials; + } + + // Skip the first 4 bytes (numCredentials as Int type) + // The codec doesn't know about this Int, so we skip it manually + let remainingBytes: Uint8Array = credentialBytes.slice(4); + let moreCredentials = true; + + do { + try { + const unpacked = codec.UnpackPrefix(remainingBytes); + credentials.push(unpacked[0] as Credential); + remainingBytes = unpacked[1] as Uint8Array; + } catch (e) { + moreCredentials = false; + } + } while (remainingBytes.length > 0 && moreCredentials); + + return credentials; + } + /** @inheritdoc */ from(raw: string): TransactionBuilder { utils.validateRawTransaction(raw); @@ -24,21 +55,43 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const network = this._coinConfig.network as FlareNetwork; let tx: any; + let credentials: Credential[] = []; + try { txSource = 'EVM'; const evmManager = FlareUtils.getManagerForVM('EVM'); - tx = evmManager.unpackTransaction(rawBuffer); - const blockchainId = tx.getBlockchainId(); + // Use getCodecFromBuffer to get both codec and remaining bytes + const [codec, txBytes] = evmManager.getCodecFromBuffer(rawBuffer); + // UnpackPrefix returns [transaction, remainingBytes] + const [transaction, credentialBytes] = codec.UnpackPrefix(txBytes) as [any, Uint8Array]; + tx = transaction; + + // Extract credentials from remaining bytes using codec + if (credentialBytes.length > 4) { + credentials = this.extractCredentialsWithCodec(credentialBytes, codec); + } + + const blockchainId = tx.getBlockchainId(); if (blockchainId === network.cChainBlockchainID) { console.log('Parsed as EVM transaction on C-Chain'); } } catch (e) { txSource = 'PVM'; const pvmManager = FlareUtils.getManagerForVM('PVM'); - tx = pvmManager.unpackTransaction(rawBuffer); - const blockchainId = tx.getBlockchainId(); + // Use getCodecFromBuffer to get both codec and remaining bytes + const [codec, txBytes] = pvmManager.getCodecFromBuffer(rawBuffer); + // UnpackPrefix returns [transaction, remainingBytes] + const [transaction, credentialBytes] = codec.UnpackPrefix(txBytes) as [any, Uint8Array]; + tx = transaction; + + // Extract credentials from remaining bytes using codec + if (credentialBytes.length > 4) { + credentials = this.extractCredentialsWithCodec(credentialBytes, codec); + } + + const blockchainId = tx.getBlockchainId(); if (blockchainId === network.blockchainID) { console.log('Parsed as PVM transaction on P-Chain'); } @@ -47,17 +100,21 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { if (txSource === 'EVM') { if (ExportInCTxBuilder.verifyTxType(tx._type)) { const exportBuilder = this.getExportInCBuilder(); - exportBuilder.initBuilder(tx as evmSerial.ExportTx, rawBuffer); + exportBuilder.initBuilder(tx as evmSerial.ExportTx, rawBuffer, credentials); return exportBuilder; + } else if (ImportInCTxBuilder.verifyTxType(tx._type)) { + const importBuilder = this.getImportInCBuilder(); + importBuilder.initBuilder(tx as evmSerial.ImportTx, rawBuffer, credentials); + return importBuilder; } } else if (txSource === 'PVM') { if (ImportInPTxBuilder.verifyTxType(tx._type)) { const importBuilder = this.getImportInPBuilder(); - importBuilder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer); + importBuilder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer, credentials); return importBuilder; } else if (ExportInPTxBuilder.verifyTxType(tx._type)) { const exportBuilder = this.getExportInPBuilder(); - exportBuilder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer); + exportBuilder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials); return exportBuilder; } } diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index a47eca3165..3281bba55a 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -1,12 +1,4 @@ -import { - Signature, - TransferableOutput, - TransferOutput, - TypeSymbols, - Id, - Credential, - utils as FlareUtils, -} from '@flarenetwork/flarejs'; +import { Signature, TransferableOutput, TransferOutput, TypeSymbols, Id } from '@flarenetwork/flarejs'; import { BaseUtils, Entry, @@ -397,158 +389,6 @@ 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: [] }; - } - } - - /** - * Parse credentials from raw bytes at a specific offset - * This is useful when the standard extraction fails due to serialization differences - * @param rawBytes Raw transaction bytes including credentials - * @param offset Byte offset where credentials start - * @returns Array of parsed credentials - */ - parseCredentialsAtOffset(rawBytes: Buffer, offset: number): Credential[] { - try { - if (rawBytes.length <= offset + 4) { - return []; - } - - const credentialBytes = rawBytes.slice(offset); - const numCredentials = credentialBytes.readUInt32BE(0); - - if (numCredentials === 0) { - return []; - } - - const credentials: Credential[] = []; - let pos = 4; - - for (let i = 0; i < numCredentials; i++) { - if (pos + 8 > credentialBytes.length) { - break; - } - - // Read type ID (4 bytes) - Type ID 9 = secp256k1 credential - const typeId = credentialBytes.readUInt32BE(pos); - pos += 4; - - if (typeId !== 9) { - continue; - } - - // Read number of signatures (4 bytes) - const numSigs = credentialBytes.readUInt32BE(pos); - pos += 4; - - // Parse all signatures for this credential - const signatures: Signature[] = []; - for (let j = 0; j < numSigs; j++) { - if (pos + 65 > credentialBytes.length) { - break; - } - const sigBytes = Buffer.from(credentialBytes.slice(pos, pos + 65)); - signatures.push(new Signature(sigBytes)); - pos += 65; - } - - if (signatures.length > 0) { - credentials.push(new Credential(signatures)); - } - } - - return credentials; - } catch { - return []; - } - } - /** * FlareJS wrapper to recover signature * @param network diff --git a/modules/sdk-coin-flrp/test/resources/transactionData/importInC.ts b/modules/sdk-coin-flrp/test/resources/transactionData/importInC.ts new file mode 100644 index 0000000000..210e1106cc --- /dev/null +++ b/modules/sdk-coin-flrp/test/resources/transactionData/importInC.ts @@ -0,0 +1,46 @@ +export const IMPORT_IN_C = { + txhash: '6mbzDvpNxZ6DyGxpzv6m3Q5CWBwDs8GJxsiBzf9EjprZqAoBj', + unsignedHex: + '0x0000000000000000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479000000000000000000000000000000000000000000000000000000000000000000000001fcea1c0e2cb7e3d77c993eb74ee05d98c24325ded1918e8a0595c96a789e2f790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000001dcd65000000000200000000000000010000000117dbd11b9dd1c9be337353db7c14f9fb3662e5b5000000001d8119c058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd', + halfSignedSignature: + '0xd365ef7ce45aebc4e81bc03f600867f515cebb25c4a0e8e1f06d9fe0a00d41fd2efac6c6df392e5f92e271c57486e39425537da7cafbb085cd1bd21aff06955d00', + halfSigntxHex: + '0x0000000000000000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479000000000000000000000000000000000000000000000000000000000000000000000001fcea1c0e2cb7e3d77c993eb74ee05d98c24325ded1918e8a0595c96a789e2f790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000001dcd65000000000200000000000000010000000117dbd11b9dd1c9be337353db7c14f9fb3662e5b5000000001d8119c058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000010000000900000002d365ef7ce45aebc4e81bc03f600867f515cebb25c4a0e8e1f06d9fe0a00d41fd2efac6c6df392e5f92e271c57486e39425537da7cafbb085cd1bd21aff06955d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + fullSigntxHex: + '0x0000000000000000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479000000000000000000000000000000000000000000000000000000000000000000000001fcea1c0e2cb7e3d77c993eb74ee05d98c24325ded1918e8a0595c96a789e2f790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000001dcd65000000000200000000000000010000000117dbd11b9dd1c9be337353db7c14f9fb3662e5b5000000001d8119c058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000010000000900000002d365ef7ce45aebc4e81bc03f600867f515cebb25c4a0e8e1f06d9fe0a00d41fd2efac6c6df392e5f92e271c57486e39425537da7cafbb085cd1bd21aff06955d0070d2ca9711622142610ddd347e482cbe5dc45aeafe66876bb82bfd57581300045b8457d804cc1b8f2efc10401367e5919b1912ee26d2d48c06cf82dc3f146acd00', + fullSignedSignature: + '0x70d2ca9711622142610ddd347e482cbe5dc45aeafe66876bb82bfd57581300045b8457d804cc1b8f2efc10401367e5919b1912ee26d2d48c06cf82dc3f146acd00', + + outputs: [ + { + outputID: 0, + amount: '500000000', + txid: '2vPMx8P63adgBae7GAWFx7qvJDwRmMnDCyKddHRBXWhysjX4BP', + outputidx: '1', + addresses: [ + '0x3329be7d01cd3ebaae6654d7327dd9f17a2e1581', + '0x7e918a5e8083ae4c9f2f0ed77055c24bf3665001', + '0xc7324437c96c7c8a6a152da2385c1db5c3ab1f91', + ], + threshold: 2, + }, + ], + amount: '500000000', + pAddresses: [ + 'P-costwo1xv5mulgpe5lt4tnx2ntnylwe79azu9vpja6lut', + 'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu', + 'P-costwo1cueygd7fd37g56s49k3rshqakhp6k8u3adzt6m', + ], + privateKeys: [ + '26a38e543bcb6cfa52d2b78d4c31330d38f5e84dcdb0be1df72722d33e4c1940', + 'ef576892dd582d93914a3dba3b77cc4e32e470c32f4127817345473aae719d14', + 'a408583e8ba09bc619c2cdd8f89f09839fddf6f3929def25251f1aa266ff7d24', + ], + to: '0x17Dbd11B9dD1c9bE337353db7C14f9fb3662E5B5', + sourceChainId: 'vE8M98mEQH6wk56sStD1ML8HApTgSqfJZLk9gQ3Fsd4i6m3Bi', + threshold: 2, + fee: '5000000', + locktime: 0, + INVALID_CHAIN_ID: 'wrong chain id', + VALID_C_CHAIN_ID: 'yH8D7ThNJkxmtkuv2jgBa4P1Rn3Qpr4pPr7QYNfcdoS6k6HWp', +}; diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts new file mode 100644 index 0000000000..0fb01cda2c --- /dev/null +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -0,0 +1,52 @@ +import assert from 'assert'; +import 'should'; +import { TransactionBuilderFactory, DecodedUtxoObj } from '../../../src/lib'; +import { coins } from '@bitgo/statics'; +import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC'; +import signFlowTest from './signFlowTestSuit'; + +describe('Flrp Import In C Tx Builder', () => { + const factory = new TransactionBuilderFactory(coins.get('tflrp')); + describe('validate txBuilder fields', () => { + const txBuilder = factory.getImportInCBuilder(); + + it('should fail validate Utxos empty string', () => { + assert.throws( + () => { + txBuilder.validateUtxos([]); + }, + (e: any) => e.message === 'UTXOs array cannot be empty' + ); + }); + + it('should fail validate Utxos without amount field', () => { + assert.throws( + () => { + txBuilder.validateUtxos([{ outputID: '' } as any as DecodedUtxoObj]); + }, + (e: any) => e.message === 'UTXO missing required field: amount' + ); + }); + }); + + signFlowTest({ + transactionType: 'Import C2P', + newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')), + newTxBuilder: () => + new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInCBuilder() + .threshold(testData.threshold) + .fromPubKey(testData.pAddresses) + .utxos(testData.outputs) + .to(testData.to) + .feeRate(testData.fee), + unsignedTxHex: testData.unsignedHex, + halfSignedTxHex: testData.halfSigntxHex, + fullSignedTxHex: testData.fullSigntxHex, + privateKey: { + prv1: testData.privateKeys[0], + prv2: testData.privateKeys[1], + }, + txHash: testData.txhash, + }); +}); diff --git a/modules/sdk-coin-flrp/test/unit/lib/signFlowTestSuit.ts b/modules/sdk-coin-flrp/test/unit/lib/signFlowTestSuit.ts index f383940905..a8018dcd0e 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/signFlowTestSuit.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/signFlowTestSuit.ts @@ -87,11 +87,7 @@ export default function signFlowTestSuit(data: signFlowTestSuitArgs): void { tx.id.should.equal(data.txHash); }); - // TODO: Known limitation - When loading from half-signed tx and adding second signature, - // the first signature is not preserved correctly due to FlareJS credential extraction limitations. - // Full signing works when done in a single flow (see "Should full sign a tx for same values" test). - // This test should be re-enabled once FlareJS credential parsing is improved. - xit('Should full sign a tx from half signed raw tx', async () => { + it('Should full sign a tx from half signed raw tx', async () => { const txBuilder = data.newTxFactory().from(data.halfSignedTxHex); txBuilder.sign({ key: data.privateKey.prv2 }); const tx = await txBuilder.build();