Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;

Expand All @@ -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),
Expand All @@ -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
)
)
),
]
Expand Down
24 changes: 19 additions & 5 deletions modules/sdk-coin-flrp/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<TransactionFee> = {};
// Store original raw signed bytes to preserve exact format when re-serializing
public _rawSignedBytes: Buffer | undefined;

constructor(coinConfig: Readonly<CoinConfig>) {
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;
}
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-coin-flrp/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Expand Down
7 changes: 3 additions & 4 deletions modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down
100 changes: 99 additions & 1 deletion modules/sdk-coin-flrp/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
33 changes: 19 additions & 14 deletions modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading