From 7c15fce0382df8a3240a025d90f0bc5aba5660c2 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 4 Nov 2025 21:14:19 +0530 Subject: [PATCH 1/7] feat(sdk-coin-flrp): enhance transaction builders and utils with new methods Ticket: WIN-7770 --- .../src/lib/atomicTransactionBuilder.ts | 56 ++++++++- modules/sdk-coin-flrp/src/lib/index.ts | 1 + .../src/lib/transactionBuilderFactory.ts | 119 +++++++++--------- modules/sdk-coin-flrp/src/lib/utils.ts | 24 +++- 4 files changed, 136 insertions(+), 64 deletions(-) diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 97abda8650..fbffd5be73 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -35,7 +35,7 @@ import { FLARE_ATOMIC_PARSED_PREFIX, HEX_ENCODING, } from './constants'; -import { createFlexibleHexRegex } from './utils'; +import utils, { createFlexibleHexRegex } from './utils'; /** * Flare P-chain atomic transaction builder with FlareJS credential support. @@ -420,4 +420,58 @@ export abstract class AtomicTransactionBuilder { ); } } + + /** + * Threshold is an int that names the number of unique signatures required to spend the output. + * Must be less than or equal to the length of Addresses. + * @param {number} + */ + threshold(value: number): this { + this.validateThreshold(value); + this.transaction._threshold = value; + return this; + } + + /** + * Validates the threshold + * @param threshold + */ + validateThreshold(threshold: number): void { + if (!threshold || threshold !== 2) { + throw new BuildTransactionError('Invalid transaction: threshold must be set to 2'); + } + } + + /** + * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output. + * @param {string | string[]} senderPubKey + */ + // TODO: check the format of the public keys + fromPubKey(senderPubKey: string | string[]): this { + const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; + this.transaction._fromAddresses = pubKeys.map(utils.parseAddress).toString().split(','); + return this; + } + + /** + * Locktime is a long that contains the unix timestamp that this output can be spent after. + * The unix timestamp is specific to the second. + * @param value + */ + locktime(value: number): this { + const locktime = BigInt(value); + this.validateLocktime(locktime); + this.transaction._locktime = locktime; + return this; + } + + /** + * Validates locktime + * @param locktime + */ + validateLocktime(locktime: bigint): void { + if (!locktime || locktime < 0n) { + throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); + } + } } diff --git a/modules/sdk-coin-flrp/src/lib/index.ts b/modules/sdk-coin-flrp/src/lib/index.ts index 2ac8d8c7bf..c6e322b5de 100644 --- a/modules/sdk-coin-flrp/src/lib/index.ts +++ b/modules/sdk-coin-flrp/src/lib/index.ts @@ -5,6 +5,7 @@ export { KeyPair } from './keyPair'; export { Utils }; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Transaction } from './transaction'; +export { TransactionBuilder } from './transactionBuilder'; export { AtomicTransactionBuilder } from './atomicTransactionBuilder'; export { AtomicInCTransactionBuilder } from './atomicInCTransactionBuilder'; export { ImportInCTxBuilder } from './importInCTxBuilder'; diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index 73066ffc28..559c64c66c 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -1,51 +1,11 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { NotImplementedError, TransactionType } from '@bitgo/sdk-core'; import { AtomicTransactionBuilder } from './atomicTransactionBuilder'; - -// Placeholder builders - basic implementations for testing -export class ExportTxBuilder extends AtomicTransactionBuilder { - protected get transactionType(): TransactionType { - return TransactionType.Export; - } - - constructor(coinConfig: Readonly) { - super(coinConfig); - // Don't throw error, allow placeholder functionality - } -} - -export class ImportTxBuilder extends AtomicTransactionBuilder { - protected get transactionType(): TransactionType { - return TransactionType.Import; - } - - constructor(coinConfig: Readonly) { - super(coinConfig); - // Don't throw error, allow placeholder functionality - } -} - -export class ValidatorTxBuilder extends AtomicTransactionBuilder { - protected get transactionType(): TransactionType { - return TransactionType.AddValidator; - } - - constructor(coinConfig: Readonly) { - super(coinConfig); - // Don't throw error, allow placeholder functionality - } -} - -export class DelegatorTxBuilder extends AtomicTransactionBuilder { - protected get transactionType(): TransactionType { - return TransactionType.AddDelegator; - } - - constructor(coinConfig: Readonly) { - super(coinConfig); - // Don't throw error, allow placeholder functionality - } -} +import { ImportInCTxBuilder } from './importInCTxBuilder'; +import { ValidatorTxBuilder } from './validatorTxBuilder'; +import { PermissionlessValidatorTxBuilder } from './permissionlessValidatorTxBuilder'; +import { ExportInCTxBuilder } from './exportInCTxBuilder'; +import { ExportInPTxBuilder } from './exportInPTxBuilder'; +import { ImportInPTxBuilder } from './importInPTxBuilder'; /** * Factory for Flare P-chain transaction builders @@ -70,7 +30,7 @@ export class TransactionBuilderFactory { // Create a mock export builder for now // In the future, this will parse the transaction and determine the correct type - const builder = new ExportTxBuilder(this._coinConfig); + const builder = new ExportInPTxBuilder(this._coinConfig); // Initialize with the hex data (placeholder) builder.initBuilder({ txHex }); @@ -79,21 +39,56 @@ export class TransactionBuilderFactory { } /** - * Create a transaction builder for a specific type - * @param type - Transaction type + * Initialize Validator builder + * + * @returns {ValidatorTxBuilder} the builder initialized */ - getBuilder(type: TransactionType): AtomicTransactionBuilder { - switch (type) { - case TransactionType.Export: - return new ExportTxBuilder(this._coinConfig); - case TransactionType.Import: - return new ImportTxBuilder(this._coinConfig); - case TransactionType.AddValidator: - return new ValidatorTxBuilder(this._coinConfig); - case TransactionType.AddDelegator: - return new DelegatorTxBuilder(this._coinConfig); - default: - throw new NotImplementedError(`Transaction type ${type} not supported`); - } + getValidatorBuilder(): ValidatorTxBuilder { + return new ValidatorTxBuilder(this._coinConfig); + } + + /** + * Initialize Permissionless Validator builder + * + * @returns {PermissionlessValidatorTxBuilder} the builder initialized + */ + getPermissionlessValidatorTxBuilder(): PermissionlessValidatorTxBuilder { + return new PermissionlessValidatorTxBuilder(this._coinConfig); + } + + /** + * Export Cross chain transfer + * + * @returns {ExportInPTxBuilder} the builder initialized + */ + getExportBuilder(): ExportInPTxBuilder { + return new ExportInPTxBuilder(this._coinConfig); + } + + /** + * Import Cross chain transfer + * + * @returns {ImportInPTxBuilder} the builder initialized + */ + getImportBuilder(): ImportInPTxBuilder { + return new ImportInPTxBuilder(this._coinConfig); + } + + /** + * Import in C chain Cross chain transfer + * + * @returns {ImportInCTxBuilder} the builder initialized + */ + getImportInCBuilder(): ImportInCTxBuilder { + return new ImportInCTxBuilder(this._coinConfig); + } + + /** + * Export in C chain Cross chain transfer + * + * @returns {ExportInCTxBuilder} the builder initialized + */ + getExportInCBuilder(): ExportInCTxBuilder { + return new ExportInCTxBuilder(this._coinConfig); } } diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index b06b2608f0..588a930212 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -1,5 +1,5 @@ import { TransferableOutput } from '@flarenetwork/flarejs'; -import { bech32 } from 'bech32'; +import * as bech32 from 'bech32'; import bs58 from 'bs58'; import { BaseUtils, @@ -53,6 +53,27 @@ export class Utils implements BaseUtils { return `${prefix}-${bech32.encode(hrp, words)}`; }; + /** + * Convert a NodeID string to a Buffer. This is a Flare-specific implementation + * that doesn't rely on the Avalanche dependency. + * + * NodeIDs in Flare follow the format: NodeID- + * + * @param {string} nodeID - The NodeID string to convert + * @returns {Buffer} - The decoded NodeID as a Buffer + */ + public NodeIDStringToBuffer = (nodeID: string): Buffer => { + // Remove 'NodeID-' prefix if present + const cleanNodeID = nodeID.startsWith('NodeID-') ? nodeID.slice(7) : nodeID; + + try { + // Decode base58 string to buffer + return Buffer.from(bs58.decode(cleanNodeID)); + } catch (error) { + throw new Error(`Invalid NodeID format: ${error instanceof Error ? error.message : String(error)}`); + } + }; + public includeIn(walletAddresses: string[], otxoOutputAddresses: string[]): boolean { return walletAddresses.map((a) => otxoOutputAddresses.includes(a)).reduce((a, b) => a && b, true); } @@ -126,6 +147,7 @@ export class Utils implements BaseUtils { } } + // TODO add test cases for this method public parseAddress = (address: string): Buffer => { return this.stringToAddress(address); }; From 47c0ca3acd629d2b81c76079a0d403c5f300769c Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 4 Nov 2025 21:45:48 +0530 Subject: [PATCH 2/7] fix(utils): update bech32 import to named import TICKET: WIN-7770 --- modules/sdk-coin-flrp/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 588a930212..6674c20945 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -1,5 +1,5 @@ import { TransferableOutput } from '@flarenetwork/flarejs'; -import * as bech32 from 'bech32'; +import { bech32 } from 'bech32'; import bs58 from 'bs58'; import { BaseUtils, From 71eae4b210e8d5c42c80028ad3f6fac04eccc1f5 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Thu, 6 Nov 2025 11:39:05 +0530 Subject: [PATCH 3/7] feat(export): enhance ExportInCTxBuilder with address validation and test data TICKET: WIN-7770 --- .../src/lib/atomicTransactionBuilder.ts | 2 +- .../src/lib/exportInCTxBuilder.ts | 9 +- modules/sdk-coin-flrp/src/lib/utils.ts | 20 +- .../resources/transactionData/exportInC.ts | 24 + .../test/unit/lib/exportInCTxBuilder.ts | 665 ++---------------- 5 files changed, 104 insertions(+), 616 deletions(-) create mode 100644 modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index fbffd5be73..cecdea29da 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -449,7 +449,7 @@ export abstract class AtomicTransactionBuilder { // TODO: check the format of the public keys fromPubKey(senderPubKey: string | string[]): this { const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; - this.transaction._fromAddresses = pubKeys.map(utils.parseAddress).toString().split(','); + this.transaction._fromAddresses = pubKeys.map(utils.parseAddress); return this; } diff --git a/modules/sdk-coin-flrp/src/lib/exportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/exportInCTxBuilder.ts index dddb9fe41a..cc3be4633e 100644 --- a/modules/sdk-coin-flrp/src/lib/exportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/exportInCTxBuilder.ts @@ -15,6 +15,7 @@ import { NETWORK_ID_PROP, BLOCKCHAIN_ID_PROP, } from './constants'; +import utils from './utils'; // Lightweight interface placeholders replacing Avalanche SDK transaction shapes interface FlareExportInputShape { @@ -95,8 +96,12 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { * @param pAddresses */ to(pAddresses: string | string[]): this { - const pubKeys = Array.isArray(pAddresses) ? pAddresses : pAddresses.split('~'); - // For now ensure they are stored as bech32 / string addresses directly + const pubKeys = pAddresses instanceof Array ? pAddresses : pAddresses.split('~'); + if (!utils.isValidAddress(pubKeys)) { + throw new BuildTransactionError('Invalid to address'); + } + + // TODO need to check if address should be string or Buffer this.transaction._to = pubKeys.map((a) => a.toString()); return this; } diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 6674c20945..8473c9506b 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -62,6 +62,7 @@ export class Utils implements BaseUtils { * @param {string} nodeID - The NodeID string to convert * @returns {Buffer} - The decoded NodeID as a Buffer */ + // TODO add test cases for this method public NodeIDStringToBuffer = (nodeID: string): Buffer => { // Remove 'NodeID-' prefix if present const cleanNodeID = nodeID.startsWith('NodeID-') ? nodeID.slice(7) : nodeID; @@ -148,19 +149,30 @@ export class Utils implements BaseUtils { } // TODO add test cases for this method - public parseAddress = (address: string): Buffer => { - return this.stringToAddress(address); + public parseAddress = (address: string): string => { + return this.stringToAddress(address).toString(HEX_ENCODING); }; public stringToAddress = (address: string, hrp?: string): Buffer => { + // Handle hex addresses + if (address.startsWith('0x')) { + return Buffer.from(address.slice(2), 'hex'); + } + + // Handle raw hex without 0x prefix + if (/^[0-9a-fA-F]{40}$/.test(address)) { + return Buffer.from(address, 'hex'); + } + + // Handle Bech32 addresses const parts = address.trim().split('-'); if (parts.length < 2) { - throw new Error('Error - Valid address should include -'); + throw new Error('Error - Valid bech32 address should include -'); } const split = parts[1].lastIndexOf('1'); if (split < 0) { - throw new Error('Error - Valid address must include separator (1)'); + throw new Error('Error - Valid bech32 address must include separator (1)'); } const humanReadablePart = parts[1].slice(0, split); diff --git a/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts b/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts new file mode 100644 index 0000000000..f84fe9cd0d --- /dev/null +++ b/modules/sdk-coin-flrp/test/resources/transactionData/exportInC.ts @@ -0,0 +1,24 @@ +// Test data for building export transactions with multiple P-addresses +export const EXPORT_IN_C = { + unsignedTxHex: + '0x000000000001000000057fc93d85c6d62c5b2ac0b519c87010ea5294012d1e407030d6acd0021cac10d500000000000000000000000000000000000000000000000000000000000000000000000147c0b1f5d366ea8f1d0cd2ce108321d2be3b338600000000009896803d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000000000009000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000700000000008954270000000000000000000000020000000320829837bfba5d602b19cb9102b99eb3f895d5e47c71b9ae100e813e6332eddad2554ec12a0591fcbb6de9adcfbf2e0dfeffbe7792afd0c4085fdd370000000100000009000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000accf9fe1', + fullsigntxHex: + '0x000000000001000000057fc93d85c6d62c5b2ac0b519c87010ea5294012d1e407030d6acd0021cac10d500000000000000000000000000000000000000000000000000000000000000000000000147c0b1f5d366ea8f1d0cd2ce108321d2be3b338600000000009896803d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000000000009000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000700000000008954270000000000000000000000020000000320829837bfba5d602b19cb9102b99eb3f895d5e47c71b9ae100e813e6332eddad2554ec12a0591fcbb6de9adcfbf2e0dfeffbe7792afd0c4085fdd37000000010000000900000001175782fa48b5b308d8f378832e5e637febb1fbcbec8f75fd6a65b657418575777b45c17a01b548a559d99e1a570d1efebe9b595dca383b0e39d5a961f16dd27500bdc89d2b', + txhash: 'jHRxuZjnSHYNwWpUUyob7RpfHwj1wfuQa8DGWQrkDh2RQ5Jb3', + + xPrivateKey: + 'xprv9s21ZrQH143K2DW9jvDoAkVpRKi5V9XhZaVdoUcqoYPPQ9wRrLNT6VGgWBbRoSYB39Lak6kXgdTM9T3QokEi5n2JJ8EdggHLkZPX8eDiBu1', + privateKey: 'a533d8419d4518e11cd8d9f049c73a8bdaf003d6602319f967ce3c243e646ba5', + amount: '8999975', + cHexAddress: '0x47c0b1f5d366ea8f1d0cd2ce108321d2be3b3386', + pAddresses: [ + 'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq', + 'P-costwo1n4a86kc3td6nvmwm4xh0w78mc5jjxc9g8w6en0', + 'P-costwo1nhm2vw8653f3qwtj3kl6qa359kkt6y9r7qgljv', + ], + mainAddress: 'P-costwo1q0ssshmwz3k77k3v0wkfr0j64dvhzzaaf9wdhq', + targetChainId: '11111111111111111111111111111111LpoYY', + nonce: 9, + threshold: 2, + fee: '25', +}; diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts index 7d958a8b07..a2c420cc16 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts @@ -1,658 +1,105 @@ import { coins } from '@bitgo/statics'; import { BuildTransactionError } from '@bitgo/sdk-core'; import * as assert from 'assert'; -import { ExportInCTxBuilder } from '../../../src/lib/exportInCTxBuilder'; +import { TransactionBuilderFactory } from '../../../src/lib/transactionBuilderFactory'; +import { EXPORT_IN_C as testData } from '../../resources/transactionData/exportInC'; describe('ExportInCTxBuilder', function () { - const coinConfig = coins.get('tflrp'); - let builder: ExportInCTxBuilder; + const factory = new TransactionBuilderFactory(coins.get('tflrp')); + const txBuilder = factory.getExportInCBuilder(); - beforeEach(function () { - builder = new ExportInCTxBuilder(coinConfig); - }); - - describe('Constructor', function () { - it('should initialize with coin config', function () { - assert.ok(builder); - assert.ok(builder instanceof ExportInCTxBuilder); - }); - - it('should extend AtomicInCTransactionBuilder', function () { - // Test inheritance - assert.ok(builder); - }); - }); - - describe('UTXO Override', function () { - it('should throw error when trying to set UTXOs', function () { - const mockUtxos = [{ id: 'test' }]; - - assert.throws( - () => { - builder.utxos(mockUtxos); - }, - BuildTransactionError, - 'Should reject UTXOs for C-chain export transactions' - ); - }); - - it('should throw error for empty UTXO array', function () { - assert.throws( - () => { - builder.utxos([]); - }, - BuildTransactionError, - 'Should reject empty UTXO array' - ); - }); - - it('should throw error for any UTXO input', function () { - const testCases = [[], [{}], ['invalid'], null, undefined]; - - testCases.forEach((testCase, index) => { - assert.throws( - () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - builder.utxos(testCase as any); - }, - BuildTransactionError, - `Test case ${index} should throw error` - ); - }); + describe('utxos', function () { + it('should throw an error when utxos are used', async function () { + assert.throws(() => { + txBuilder.utxos([]); + }, new BuildTransactionError('utxos are not required in Export Tx in C-Chain')); }); }); - describe('Amount Management', function () { - it('should set valid positive amounts', function () { - const validAmounts = ['1000', '1000000000000000000', '999999999999999999']; + describe('amount', function () { + it('should accept valid amounts in different formats', function () { + const validAmounts = [BigInt(1000), '1000', 1000]; validAmounts.forEach((amount) => { assert.doesNotThrow(() => { - builder.amount(amount); - }, `Should accept amount: ${amount}`); - }); - }); - - it('should set bigint amounts', function () { - const bigintAmounts = [1000n, 1000000000000000000n, 999999999999999999n]; - - bigintAmounts.forEach((amount) => { - assert.doesNotThrow(() => { - builder.amount(amount); - }, `Should accept bigint amount: ${amount}`); - }); - }); - - it('should set numeric amounts', function () { - const numericAmounts = [1000, 2000000, 999999999]; - - numericAmounts.forEach((amount) => { - assert.doesNotThrow(() => { - builder.amount(amount); - }, `Should accept numeric amount: ${amount}`); - }); - }); - - it('should reject zero amount', function () { - assert.throws(() => { - builder.amount(0); - }, /Amount must be positive/); - - assert.throws(() => { - builder.amount('0'); - }, /Amount must be positive/); - - assert.throws(() => { - builder.amount(0n); - }, /Amount must be positive/); - }); - - it('should reject negative amounts', function () { - const negativeAmounts = ['-1000', '-1']; - - negativeAmounts.forEach((amount) => { - assert.throws( - () => { - builder.amount(amount); - }, - BuildTransactionError, - `Should reject negative amount: ${amount}` - ); - }); - }); - - it('should handle large amounts', function () { - const largeAmounts = [ - '100000000000000000000000', // Very large amount - '18446744073709551615', // Near uint64 max - BigInt('999999999999999999999999999999'), - ]; - - largeAmounts.forEach((amount) => { - assert.doesNotThrow(() => { - builder.amount(amount); - }, `Should handle large amount: ${amount}`); + txBuilder.amount(amount); + }); }); }); - it('should chain amount setting with other methods', function () { - const amount = '1000000000000000000'; - const nonce = 1n; + it('should throw error for invalid amounts', function () { + const invalidAmounts = ['0', '-1']; - assert.doesNotThrow(() => { - builder.amount(amount).nonce(nonce); + invalidAmounts.forEach((amount) => { + assert.throws(() => { + txBuilder.amount(amount); + }, BuildTransactionError); }); }); }); - describe('Nonce Management', function () { - it('should set valid nonce values', function () { - const validNonces = [0n, 1n, 1000n, 999999999999n]; + describe('nonce', function () { + it('should accept valid nonces in different formats', function () { + const validNonces = [BigInt(1), '1', 1, 0]; validNonces.forEach((nonce) => { assert.doesNotThrow(() => { - builder.nonce(nonce); - }, `Should accept nonce: ${nonce}`); - }); - }); - - it('should set string nonce values', function () { - const stringNonces = ['0', '1', '1000', '999999999999']; - - stringNonces.forEach((nonce) => { - assert.doesNotThrow(() => { - builder.nonce(nonce); - }, `Should accept string nonce: ${nonce}`); - }); - }); - - it('should set numeric nonce values', function () { - const numericNonces = [0, 1, 1000, 999999]; - - numericNonces.forEach((nonce) => { - assert.doesNotThrow(() => { - builder.nonce(nonce); - }, `Should accept numeric nonce: ${nonce}`); - }); - }); - - it('should reject negative nonce values', function () { - const negativeNonces = [-1n, -1000n]; - - negativeNonces.forEach((nonce) => { - assert.throws( - () => { - builder.nonce(nonce); - }, - BuildTransactionError, - `Should reject negative nonce: ${nonce}` - ); - }); - }); - - it('should reject negative string nonce values', function () { - const negativeStringNonces = ['-1', '-1000']; - - negativeStringNonces.forEach((nonce) => { - assert.throws( - () => { - builder.nonce(nonce); - }, - BuildTransactionError, - `Should reject negative string nonce: ${nonce}` - ); - }); - }); - - it('should handle large nonce values', function () { - const largeNonces = [ - '18446744073709551615', // Max uint64 - BigInt('999999999999999999999999999999'), - 1000000000000000000n, - ]; - - largeNonces.forEach((nonce) => { - assert.doesNotThrow(() => { - builder.nonce(nonce); - }, `Should handle large nonce: ${nonce}`); - }); - }); - - it('should chain nonce setting with other methods', function () { - const nonce = 123n; - const amount = '1000000000000000000'; - - assert.doesNotThrow(() => { - builder.nonce(nonce).amount(amount); - }); - }); - }); - - describe('Destination Address Management', function () { - it('should set single destination address', function () { - const singleAddress = 'P-flare1destination'; - - assert.doesNotThrow(() => { - builder.to(singleAddress); - }); - }); - - it('should set multiple destination addresses', function () { - const multipleAddresses = ['P-flare1dest1', 'P-flare1dest2', 'P-flare1dest3']; - - assert.doesNotThrow(() => { - builder.to(multipleAddresses); - }); - }); - - it('should handle comma-separated addresses', function () { - const commaSeparated = 'P-flare1dest1~P-flare1dest2~P-flare1dest3'; - - assert.doesNotThrow(() => { - builder.to(commaSeparated); + txBuilder.nonce(nonce); + }); }); }); - it('should handle empty address array', function () { - assert.doesNotThrow(() => { - builder.to([]); - }); - }); - - it('should chain address setting with other methods', function () { - const addresses = ['P-flare1dest1', 'P-flare1dest2']; - const amount = '1000000000000000000'; - - assert.doesNotThrow(() => { - builder.to(addresses).amount(amount); - }); - }); - }); - - describe('Transaction Type Verification', function () { - it('should verify transaction type (placeholder returns true)', function () { - const mockTx = { type: 'export' }; - const result = ExportInCTxBuilder.verifyTxType(mockTx); - assert.strictEqual(result, true); // Placeholder always returns true - }); - - it('should handle different transaction objects', function () { - const testCases = [{}, null, undefined, { type: 'import' }, { data: 'test' }]; - - testCases.forEach((testCase, index) => { - const result = ExportInCTxBuilder.verifyTxType(testCase); - assert.strictEqual(result, true, `Test case ${index} should return true (placeholder)`); - }); - }); - - it('should verify via instance method', function () { - const mockTx = { type: 'export' }; - const result = builder.verifyTxType(mockTx); - assert.strictEqual(result, true); - }); - }); - - describe('Transaction Building', function () { - it('should handle building when transaction has credentials', function () { - const mockTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - builder.initBuilder(mockTx); - - // Should not throw when credentials exist - assert.doesNotThrow(() => { - // Access protected method via type assertion - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (builder as any).buildFlareTransaction(); - }); - }); - - it('should require amount for building', function () { - builder.nonce(1n); - builder.to(['P-flare1dest']); - - // Mock setting from addresses via transaction initialization - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - - builder.initBuilder(mockRawTx); - // Clear amount to test error - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (builder as any)._amount = undefined; - - assert.throws(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (builder as any).buildFlareTransaction(); - }, Error); - }); - }); - - describe('Transaction Initialization', function () { - it('should initialize from raw transaction object', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - - assert.doesNotThrow(() => { - builder.initBuilder(mockRawTx); - }); - }); - - it('should validate blockchain ID during initialization', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.from('wrong-blockchain'), - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - - assert.throws(() => { - builder.initBuilder(mockRawTx); - }, Error); - }); - - it('should validate single output requirement', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default // Will match default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest1'], - amount: 500000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - { - addresses: ['P-flare1dest2'], - amount: 500000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - + it('should throw error for negative nonce', function () { assert.throws(() => { - builder.initBuilder(mockRawTx); - }, BuildTransactionError); - }); - - it('should validate single input requirement', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test1', - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - { - address: 'C-flare1test2', - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 2n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - - assert.throws(() => { - builder.initBuilder(mockRawTx); - }, BuildTransactionError); - }); - - it('should validate negative fee calculation', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 500000000000000000n, // Less than output - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, // More than input - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; - - assert.throws(() => { - builder.initBuilder(mockRawTx); - }, BuildTransactionError); + txBuilder.nonce('-1'); + }, new BuildTransactionError('Nonce must be greater or equal than 0')); }); }); - describe('From Implementation', function () { - it('should handle string raw transaction', function () { - const rawString = 'hex-encoded-transaction'; - - assert.doesNotThrow(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (builder as any).fromImplementation(rawString); - }); - }); + describe('to', function () { + const txBuilder = factory.getExportInCBuilder(); - it('should handle object raw transaction', function () { - const mockRawTx = { - unsignedTx: { - networkId: 0, // Match builder's default - sourceBlockchainId: Buffer.alloc(0), // Match builder's default - destinationBlockchainId: Buffer.from('test-dest'), - inputs: [ - { - address: 'C-flare1test', - amount: 2000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - nonce: 1n, - }, - ], - outputs: [ - { - addresses: ['P-flare1dest'], - amount: 1000000000000000000n, - assetId: Buffer.alloc(0), // Match builder's default - }, - ], - }, - credentials: [], - }; + it('should accept multiple P-addresses', function () { + const pAddresses = testData.pAddresses; assert.doesNotThrow(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (builder as any).fromImplementation(mockRawTx); + txBuilder.to(pAddresses); }); }); - }); - - describe('Integration Tests', function () { - it('should handle complete export flow preparation', function () { - const amount = '1000000000000000000'; // 1 FLR - const nonce = 123n; - const toAddresses = ['P-flare1destination']; - - // Complete setup - builder.amount(amount).nonce(nonce).to(toAddresses); - - // All operations should complete without throwing - assert.ok(true); - }); - it('should handle method chaining extensively', function () { - // Test extensive method chaining + it('should accept single P-address', function () { assert.doesNotThrow(() => { - builder - .amount('5000000000000000000') // 5 FLR - .nonce(456n) - .to(['P-flare1receiver1', 'P-flare1receiver2']); + txBuilder.to(testData.pAddresses[0]); }); }); - it('should handle large transaction values', function () { - const largeAmount = '100000000000000000000000'; // 100k FLR - const largeNonce = 999999999999n; + it('should accept tilde-separated P-addresses string', function () { + const pAddresses = testData.pAddresses.join('~'); assert.doesNotThrow(() => { - builder.amount(largeAmount).nonce(largeNonce); - }); - }); - - it('should handle multiple destination addresses', function () { - const multipleDestinations = [ - 'P-flare1dest1', - 'P-flare1dest2', - 'P-flare1dest3', - 'P-flare1dest4', - 'P-flare1dest5', - ]; - - assert.doesNotThrow(() => { - builder.amount('1000000000000000000').to(multipleDestinations); + txBuilder.to(pAddresses); }); }); }); - describe('Edge Cases', function () { - it('should handle zero values appropriately', function () { - // Zero amount should be rejected - assert.throws(() => { - builder.amount(0); - }, /Amount must be positive/); - - // Zero nonce should be valid - assert.doesNotThrow(() => { - builder.nonce(0n); - }); - }); - - it('should handle maximum values', function () { - const maxBigInt = BigInt('18446744073709551615'); // Max uint64 - - assert.doesNotThrow(() => { - builder.amount(maxBigInt); - }); - - assert.doesNotThrow(() => { - builder.nonce(maxBigInt); - }); - }); - - it('should maintain state across multiple operations', function () { - // Build state incrementally - builder.amount('1000000000000000000'); - builder.nonce(123n); - builder.to(['P-flare1dest']); - - // State should be maintained across operations - assert.ok(true); + describe('should build a export txn from C to P', () => { + const newTxBuilder = () => + factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(testData.threshold) + .locktime(10) + .to(testData.pAddresses) + .feeRate(testData.fee); + + it('Should create export tx for same values', async () => { + const txBuilder = newTxBuilder(); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + rawTx.should.equal(testData.unsignedTxHex); }); }); }); From 945626974704d7a5bb6f12af691969abc9508ca9 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 11 Nov 2025 13:02:04 +0530 Subject: [PATCH 4/7] feat(transaction): add signature validation and enhance transaction signing process TICKET: WIN-7770 --- modules/sdk-coin-flrp/src/lib/keyPair.ts | 2 +- modules/sdk-coin-flrp/src/lib/transaction.ts | 165 +++++++++++++------ modules/statics/src/networks.ts | 11 +- 3 files changed, 122 insertions(+), 56 deletions(-) diff --git a/modules/sdk-coin-flrp/src/lib/keyPair.ts b/modules/sdk-coin-flrp/src/lib/keyPair.ts index 2ba161f21e..3d6e03abc6 100644 --- a/modules/sdk-coin-flrp/src/lib/keyPair.ts +++ b/modules/sdk-coin-flrp/src/lib/keyPair.ts @@ -124,7 +124,7 @@ export class KeyPair extends Secp256k1ExtendedKeyPair { * * @returns {Buffer} The address buffer derived from the public key */ - private getAddressBuffer(): Buffer { + getAddressBuffer(): Buffer { try { // Use the safe buffer method for address derivation return this.getAddressSafeBuffer(); diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 838ec92e56..b39b8de266 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -1,4 +1,4 @@ -import { UnsignedTx, Credential } from '@flarenetwork/flarejs'; +import { UnsignedTx, Credential, utils as flrpUtils, pvmSerial, secp256k1 } from '@flarenetwork/flarejs'; import { BaseKey, BaseTransaction, @@ -23,10 +23,6 @@ import { KeyPair } from './keyPair'; import utils from './utils'; import { FLR_ASSET_ID, - FLARE_TX_HEX_PLACEHOLDER, - FLARE_SIGNABLE_PAYLOAD, - FLARE_TRANSACTION_ID_PLACEHOLDER, - PLACEHOLDER_NODE_ID, HEX_ENCODING, MEMO_FIELD, DISPLAY_ORDER_BASE, @@ -35,6 +31,42 @@ import { DESTINATION_CHAIN_FIELD, } from './constants'; +/** + * Checks if a signature is empty + * @param signature + * @returns {boolean} + */ +function isEmptySignature(signature: string): boolean { + return !!signature && utils.removeHexPrefix(signature).startsWith(''.padStart(90, '0')); +} + +interface CheckSignature { + (signature: string, addressHex: string): boolean; +} + +function generateSelectorSignature(signatures: string[]): CheckSignature { + if (signatures.length > 1 && signatures.every((sig) => isEmptySignature(sig))) { + // Look for address. + return function (sig, address): boolean { + try { + if (!isEmptySignature(sig)) { + return false; + } + if (sig.startsWith('0x')) sig = sig.substring(2); + const pub = sig.substring(90); + return pub === address; + } catch (e) { + return false; + } + }; + } else { + // Look for empty string + return function (sig, address): boolean { + return isEmptySignature(sig); + }; + } +} + /** * Flare P-chain transaction implementation using FlareJS * Based on AVAX transaction patterns adapted for Flare network @@ -52,19 +84,19 @@ export class Transaction extends BaseTransaction { public _stakeAmount: bigint; public _threshold = 2; public _locktime = BigInt(0); - public _fromAddresses: string[] = []; + public _fromAddresses: string[] = []; // TODO need to check for string or Uint8Array public _rewardAddresses: string[] = []; public _utxos: DecodedUtxoObj[] = []; public _to: string[]; public _fee: Partial = {}; public _blsPublicKey: string; public _blsSignature: string; - public _memo: Uint8Array = new Uint8Array(); // FlareJS memo field + public _memo: Uint8Array = new Uint8Array(); // FlareJS memo field // TODO need to check need for this constructor(coinConfig: Readonly) { super(coinConfig); this._network = coinConfig.network as FlareNetwork; - this._assetId = FLR_ASSET_ID; // Default FLR asset + this._assetId = this._network.assetId || FLR_ASSET_ID; this._blockchainID = this._network.blockchainID || ''; this._networkID = this._network.networkID || 0; } @@ -82,14 +114,13 @@ export class Transaction extends BaseTransaction { return []; } // TODO: Extract signatures from FlareJS credentials - // For now, return placeholder - return []; + return this.credentials[0].getSignatures().filter((s) => !isEmptySignature(s)); } get credentials(): Credential[] { // TODO: Extract credentials from FlareJS transaction // For now, return empty array - return []; + return (this._flareTransaction as UnsignedTx)?.credentials; } get hasCredentials(): boolean { @@ -107,27 +138,42 @@ export class Transaction extends BaseTransaction { * @param {KeyPair} keyPair */ async sign(keyPair: KeyPair): Promise { - const prv = keyPair.getPrivateKey() as Buffer; - + const prv = keyPair.getPrivateKey() as Uint8Array; + const addressHex = keyPair.getAddressBuffer().toString('hex'); if (!prv) { throw new SigningError('Missing private key'); } - if (!this.flareTransaction) { throw new InvalidTransactionError('empty transaction to sign'); } - if (!this.hasCredentials) { throw new InvalidTransactionError('empty credentials to sign'); } - - // TODO: Implement FlareJS signing process - // This will involve: - // 1. Creating FlareJS signature using private key - // 2. Attaching signature to appropriate credential - // 3. Updating transaction with signed credentials - - throw new Error('FlareJS signing not yet implemented - placeholder'); + const unsignedTx = this._flareTransaction as UnsignedTx; + const unsignedBytes = unsignedTx.toBytes(); + + const publicKey = secp256k1.getPublicKey(prv); + if (unsignedTx.hasPubkey(publicKey)) { + const signature = await secp256k1.sign(unsignedBytes, prv); + let checkSign: CheckSignature | undefined = undefined; + unsignedTx.credentials.forEach((c, index) => { + if (checkSign === undefined) { + checkSign = generateSelectorSignature(c.getSignatures()); + } + let find = false; + c.getSignatures().forEach((sig, index) => { + if (checkSign && checkSign(sig, addressHex)) { + c.setSignature(index, signature); + find = true; + } + }); + if (!find) { + throw new SigningError( + `Private key cannot sign the transaction, address hex ${addressHex}, public key: ${publicKey}` + ); + } + }); + } } /** @@ -171,7 +217,7 @@ export class Transaction extends BaseTransaction { } toHexString(byteArray: Uint8Array): string { - return Buffer.from(byteArray).toString(HEX_ENCODING); + return flrpUtils.bufferToHex(Buffer.from(byteArray)); } /** @inheritdoc */ @@ -180,9 +226,8 @@ export class Transaction extends BaseTransaction { throw new InvalidTransactionError('Empty transaction data'); } - // TODO: Implement FlareJS transaction serialization - // For now, return placeholder - return FLARE_TX_HEX_PLACEHOLDER; + // TODO: verify and implement FlareJS transaction serialization + return this.toHexString(flrpUtils.addChecksum((this._flareTransaction as UnsignedTx).getSignedTx().toBytes())); } toJson(): TxData { @@ -222,6 +267,7 @@ export class Transaction extends BaseTransaction { TransactionType.AddDelegator, TransactionType.AddPermissionlessValidator, TransactionType.AddPermissionlessDelegator, + TransactionType.ImportToC, ]; if (!supportedTypes.includes(transactionType)) { @@ -239,9 +285,8 @@ export class Transaction extends BaseTransaction { throw new InvalidTransactionError('Empty transaction for signing'); } - // TODO: Implement FlareJS signable payload extraction - // For now, return placeholder - return Buffer.from(FLARE_SIGNABLE_PAYLOAD); + // TODO: verify and Implement FlareJS signable payload extraction + return utils.sha256((this._flareTransaction as UnsignedTx).toBytes()); } get id(): string { @@ -249,23 +294,21 @@ export class Transaction extends BaseTransaction { throw new InvalidTransactionError('Empty transaction for ID generation'); } - // TODO: Implement FlareJS transaction ID generation - // For now, return placeholder - return FLARE_TRANSACTION_ID_PLACEHOLDER; + // TODO: verify and Implement FlareJS transaction ID generation + const bufferArray = utils.sha256((this._flareTransaction as UnsignedTx).toBytes()); + return utils.cb58Encode(Buffer.from(bufferArray)); } get fromAddresses(): string[] { - return this._fromAddresses.map((address) => { - // TODO: Format addresses using FlareJS utilities - return address; - }); + return this._fromAddresses.map((a) => + flrpUtils.format(this._network.alias, this._network.hrp, Buffer.from(a, 'hex')) + ); } get rewardAddresses(): string[] { - return this._rewardAddresses.map((address) => { - // TODO: Format addresses using FlareJS utilities - return address; - }); + return this._rewardAddresses.map((a) => + flrpUtils.format(this._network.alias, this._network.hrp, Buffer.from(a, 'hex')) + ); } /** @@ -284,8 +327,12 @@ export class Transaction extends BaseTransaction { // TODO: Extract validator outputs from FlareJS transaction return [ { - address: this._nodeID || PLACEHOLDER_NODE_ID, - value: this._stakeAmount?.toString() || '0', + address: ( + (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx + ).subnetValidator.validator.nodeId.toString(), + value: ( + (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx + ).subnetValidator.validator.weight.toString(), }, ]; case TransactionType.AddDelegator: @@ -293,8 +340,12 @@ export class Transaction extends BaseTransaction { // TODO: Extract delegator outputs from FlareJS transaction return [ { - address: this._nodeID || PLACEHOLDER_NODE_ID, - value: this._stakeAmount?.toString() || '0', + address: ( + (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessDelegatorTx + ).subnetValidator.validator.nodeId.toString(), + value: ( + (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessDelegatorTx + ).subnetValidator.validator.weight.toString(), }, ]; default: @@ -309,17 +360,29 @@ export class Transaction extends BaseTransaction { get changeOutputs(): Entry[] { // TODO: Extract change outputs from FlareJS transaction // For now, return empty array - return []; + return ( + (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx + ).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); } get inputs(): FlrpEntry[] { // TODO: Extract inputs from FlareJS transaction // For now, return placeholder based on UTXOs - return this._utxos.map((utxo) => ({ - id: utxo.txid + INPUT_SEPARATOR + utxo.outputidx, - address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), - value: utxo.amount, - })); + let inputs; + switch (this.type) { + case TransactionType.AddPermissionlessValidator: + default: + inputs = ((this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx).baseTx + .inputs; + break; + } + return inputs.map((input) => { + return { + id: input.utxoID.txID.toString() + INPUT_SEPARATOR + input.utxoID.outputIdx.value(), + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: input.amount().toString(), + }; + }); } /** diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index b87879d436..2d286f0d06 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -12,8 +12,8 @@ export interface FlareNetwork extends BaseNetwork { blockchainID?: string; cChainBlockchainID?: string; networkID?: number; - hrp?: string; - alias?: string; + hrp: string; + alias: string; vm?: string; txFee?: string; maxImportFee?: string; @@ -28,6 +28,7 @@ export interface FlareNetwork extends BaseNetwork { maxStakeDuration?: string; minDelegationStake?: string; minDelegationFee?: string; + assetId?: string; } import { CoinFamily } from './base'; @@ -1789,6 +1790,7 @@ export class FlareP extends Mainnet implements FlareNetwork { maxStakeDuration = '31536000'; // 1 year minDelegationStake = '50000000000000'; // 50000 FLR minDelegationFee = '0'; + assetId = 'FLRP'; } export class FlarePTestnet extends Testnet implements FlareNetwork { @@ -1815,9 +1817,10 @@ export class FlarePTestnet extends Testnet implements FlareNetwork { maxStakeDuration = '31536000'; // 1 year minDelegationStake = '50000000000000'; // 50000 FLR minDelegationFee = '0'; + assetId = 'FLRP'; } -export class Flare extends Mainnet implements FlareNetwork, EthereumNetwork { +export class Flare extends Mainnet implements EthereumNetwork { name = 'Flarechain'; family = CoinFamily.FLR; explorerUrl = 'https://flare-explorer.flare.network/tx/'; @@ -1831,7 +1834,7 @@ export class Flare extends Mainnet implements FlareNetwork, EthereumNetwork { forwarderImplementationAddress = '0xd5fe1c1f216b775dfd30638fa7164d41321ef79b'; } -export class FlareTestnet extends Testnet implements FlareNetwork, EthereumNetwork { +export class FlareTestnet extends Testnet implements EthereumNetwork { name = 'FlarechainTestnet'; family = CoinFamily.FLR; explorerUrl = 'https://coston2-explorer.flare.network/tx/'; From ed13a760b8cbf130ebbde4ad11fd6956156d3ed7 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 11 Nov 2025 13:10:38 +0530 Subject: [PATCH 5/7] refactor(transactionBuilder): remove unused buildFlareTransaction method and add TODO for address format validation TICKET: WIN-7770 --- modules/sdk-coin-flrp/src/lib/transactionBuilder.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts index 906168ec19..d791ec199e 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts @@ -147,6 +147,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output * @param {string | string[]} senderPubKey */ + // TODO: need to check for the address format fromPubKey(senderPubKey: string | string[]): this { const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; this._transaction._fromAddresses = pubKeys; // Store as strings directly @@ -165,12 +166,6 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this; } - /** - * Build the Flare transaction using FlareJS API - * @protected - */ - protected abstract buildFlareTransaction(): Promise | void; - /** @inheritdoc */ protected fromImplementation(rawTransaction: string): Transaction { // Parse the raw transaction and initialize the builder From 1b9f3efac9ee32b581b6f7ca4b37c2d7286f3bde Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 11 Nov 2025 14:34:53 +0530 Subject: [PATCH 6/7] feat(transaction): refactor transaction builders to use deprecated classes and enhance transaction handling TICKET: WIN-7770 --- .../src/lib/atomicInCTransactionBuilder.ts | 2 +- .../atomicInCTransactionBuilderDeprecated.ts | 74 +++ .../src/lib/atomicTransactionBuilder.ts | 306 ++--------- .../lib/atomicTransactionBuilderDeprecated.ts | 477 ++++++++++++++++++ .../src/lib/transactionBuilder.ts | 39 +- modules/statics/src/networks.ts | 2 +- 6 files changed, 632 insertions(+), 268 deletions(-) create mode 100644 modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts create mode 100644 modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts diff --git a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts index 7f26bd836d..91025c8c15 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts @@ -1,4 +1,4 @@ -import { AtomicTransactionBuilder } from './atomicTransactionBuilder'; +import { AtomicTransactionBuilder } from './atomicTransactionBuilderDeprecated'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import utils from './utils'; import { BuildTransactionError } from '@bitgo/sdk-core'; diff --git a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts new file mode 100644 index 0000000000..91025c8c15 --- /dev/null +++ b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts @@ -0,0 +1,74 @@ +import { AtomicTransactionBuilder } from './atomicTransactionBuilderDeprecated'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import utils from './utils'; +import { BuildTransactionError } from '@bitgo/sdk-core'; + +interface FlareChainNetworkMeta { + blockchainID?: string; // P-chain id (external) + cChainBlockchainID?: string; // C-chain id (local) + [k: string]: unknown; +} + +interface FeeShape { + fee?: string; // legacy + feeRate?: string; // per unit rate +} + +/** + * Flare P->C atomic import/export style builder (C-chain context). This adapts the AVAXP logic + * removing direct Avalanche SDK dependencies. Network / chain ids are expected to be provided + * in the transaction._network object by a higher-level factory once Flare network constants + * are finalized. For now we CB58-decode placeholders if present and default to zero buffers. + */ +export abstract class AtomicInCTransactionBuilder extends AtomicTransactionBuilder { + // Placeholder fixed fee (can be overridden by subclasses or network config) + protected fixedFee = 0n; + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.initializeChainIds(); + } + + /** + * Set base fee (already scaled to Flare C-chain native decimals). Accept bigint | number | string. + */ + feeRate(baseFee: bigint | number | string): this { + const n = typeof baseFee === 'bigint' ? baseFee : BigInt(baseFee); + this.validateFee(n); + this.setFeeRate(n); + return this; + } + + /** + * Recreate builder state from raw tx (hex). Flare C-chain support TBD; for now validate & stash. + */ + protected fromImplementation(rawTransaction: string): { _tx?: unknown } { + // If utils has validateRawTransaction use it; otherwise basic check + if ((utils as unknown as { validateRawTransaction?: (r: string) => void }).validateRawTransaction) { + (utils as unknown as { validateRawTransaction: (r: string) => void }).validateRawTransaction(rawTransaction); + } + this.transaction.setTransaction(rawTransaction); + return this.transaction; + } + + private validateFee(fee: bigint): void { + if (fee <= 0n) { + throw new BuildTransactionError('Fee must be greater than 0'); + } + } + + private initializeChainIds(): void { + const meta = this.transaction._network as FlareChainNetworkMeta; + if (meta?.blockchainID) { + this._externalChainId = utils.cb58Decode(meta.blockchainID); + } + if (meta?.cChainBlockchainID) { + this.transaction._blockchainID = utils.cb58Decode(meta.cChainBlockchainID); + } + } + + private setFeeRate(n: bigint): void { + const currentContainer = this.transaction as unknown as { _fee: FeeShape }; + const current = currentContainer._fee || { fee: '0' }; + currentContainer._fee = { ...current, feeRate: n.toString() }; + } +} diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index cecdea29da..7e5a8a4a5b 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -1,126 +1,71 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BuildTransactionError, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; +import { BuildTransactionError } from '@bitgo/sdk-core'; import { Credential, Signature, TransferableInput, TransferableOutput } from '@flarenetwork/flarejs'; -import { TransactionExplanation, DecodedUtxoObj } from './iface'; +import { DecodedUtxoObj } from './iface'; import { ASSET_ID_LENGTH, TRANSACTION_ID_HEX_LENGTH, - PRIVATE_KEY_HEX_LENGTH, SECP256K1_SIGNATURE_LENGTH, - TRANSACTION_ID_PREFIX, - DEFAULT_NETWORK_ID, - EMPTY_BUFFER_SIZE, HEX_PREFIX, HEX_PREFIX_LENGTH, DECIMAL_RADIX, - SIGNING_METHOD, AMOUNT_STRING_ZERO, - DEFAULT_LOCKTIME, - DEFAULT_THRESHOLD, - ZERO_BIGINT, ZERO_NUMBER, - ERROR_AMOUNT_POSITIVE, - ERROR_CREDENTIALS_ARRAY, ERROR_UTXOS_REQUIRED, ERROR_SIGNATURES_ARRAY, ERROR_SIGNATURES_EMPTY, - ERROR_INVALID_PRIVATE_KEY, - ERROR_UTXOS_REQUIRED_BUILD, - ERROR_ENHANCED_BUILD_FAILED, - ERROR_ENHANCED_PARSE_FAILED, - ERROR_FLAREJS_SIGNING_FAILED, ERROR_CREATE_CREDENTIAL_FAILED, ERROR_UNKNOWN, - FLARE_ATOMIC_PREFIX, - FLARE_ATOMIC_PARSED_PREFIX, HEX_ENCODING, } from './constants'; import utils, { createFlexibleHexRegex } from './utils'; +import { TransactionBuilder } from './transactionBuilder'; /** * Flare P-chain atomic transaction builder with FlareJS credential support. * This provides the foundation for building Flare P-chain transactions with proper * credential handling using FlareJS Credential and Signature classes. */ -export abstract class AtomicTransactionBuilder { - protected readonly _coinConfig: Readonly; - // External chain id (destination) for export transactions +export abstract class AtomicTransactionBuilder extends TransactionBuilder { protected _externalChainId: Buffer | undefined; - protected _utxos: DecodedUtxoObj[] = []; - protected transaction: { - _network: Record; - _networkID: number; - _blockchainID: Buffer; - _assetId: Buffer; - _fromAddresses: string[]; - _to: string[]; - _locktime: bigint; - _threshold: number; - _fee: { fee: string; feeRate?: string; size?: number }; - hasCredentials: boolean; - _tx?: unknown; - _signature?: unknown; - setTransaction: (tx: unknown) => void; - } = { - _network: {}, - _networkID: DEFAULT_NETWORK_ID, - _blockchainID: Buffer.alloc(EMPTY_BUFFER_SIZE), - _assetId: Buffer.alloc(EMPTY_BUFFER_SIZE), - _fromAddresses: [], - _to: [], - _locktime: DEFAULT_LOCKTIME, - _threshold: DEFAULT_THRESHOLD, - _fee: { fee: AMOUNT_STRING_ZERO }, - hasCredentials: false, - setTransaction: function (_tx: unknown) { - this._tx = _tx; - }, - }; - - constructor(coinConfig: Readonly) { - this._coinConfig = coinConfig; + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.transaction._fee.fee = this.fixedFee; } - protected abstract get transactionType(): TransactionType; - /** - * Get the asset ID for Flare network transactions - * @returns Buffer containing the asset ID + * The internal chain is the one set for the coin in coinConfig.network. The external chain is the other chain involved. + * The external chain id is the source on import and the destination on export. + * + * @param {string} chainId - id of the external chain */ - protected getAssetId(): Buffer { - // Use the asset ID from transaction if already set - if (this.transaction._assetId && this.transaction._assetId.length > 0) { - return this.transaction._assetId; - } - - // For native FLR transactions, return zero-filled buffer as placeholder - // In a real implementation, this would be obtained from the network configuration - // or FlareJS API to get the actual native asset ID - return Buffer.alloc(ASSET_ID_LENGTH); + externalChainId(chainId: string | Buffer): this { + const newTargetChainId = typeof chainId === 'string' ? utils.cb58Decode(chainId) : Buffer.from(chainId); + this.validateChainId(newTargetChainId); + this._externalChainId = newTargetChainId; + return this; } - validateAmount(amount: bigint): void { - if (amount <= ZERO_BIGINT) { - throw new BuildTransactionError(ERROR_AMOUNT_POSITIVE); - } + /** + * Fee is fix for AVM atomic tx. + * + * @returns network.txFee + * @protected + */ + protected get fixedFee(): string { + return this.transaction._network.txFee; } /** - * Validates that credentials array is properly formed - * @param credentials - Array of credentials to validate + * Check the buffer has 32 byte long. + * @param chainID */ - protected validateCredentials(credentials: Credential[]): void { - if (!Array.isArray(credentials)) { - throw new BuildTransactionError(ERROR_CREDENTIALS_ARRAY); + validateChainId(chainID: Buffer): void { + if (chainID.length !== 32) { + throw new BuildTransactionError('Chain id are 32 byte size'); } - - credentials.forEach((credential, index) => { - if (!(credential instanceof Credential)) { - throw new BuildTransactionError(`Invalid credential at index ${index}`); - } - }); } /** @@ -232,14 +177,19 @@ export abstract class AtomicTransactionBuilder { } /** - * Set UTXOs for the transaction. This is required for creating inputs and outputs. - * - * @param utxos - Array of decoded UTXO objects - * @returns this builder instance for chaining + * Get the asset ID for Flare network transactions + * @returns Buffer containing the asset ID */ - utxos(utxos: DecodedUtxoObj[]): this { - this._utxos = utxos; - return this; + protected getAssetId(): Buffer { + // Use the asset ID from transaction if already set + if (this.transaction._assetId && this.transaction._assetId.length > 0) { + return Buffer.from(this.transaction._assetId); + } + + // For native FLR transactions, return zero-filled buffer as placeholder + // In a real implementation, this would be obtained from the network configuration + // or FlareJS API to get the actual native asset ID + return Buffer.alloc(ASSET_ID_LENGTH); } /** @@ -300,178 +250,4 @@ export abstract class AtomicTransactionBuilder { ); } } - - /** - * Base initBuilder used by concrete builders. For now just returns this so fluent API works. - */ - initBuilder(_tx: unknown): this { - return this; - } - - /** - * Sign transaction with private key using FlareJS compatibility - */ - sign(params: { key: string }): this { - // FlareJS signing implementation with atomic transaction support - try { - // Validate private key format (placeholder implementation) - if (!params.key || params.key.length < PRIVATE_KEY_HEX_LENGTH) { - throw new BuildTransactionError(ERROR_INVALID_PRIVATE_KEY); - } - - // Create signature structure - const signature = { - privateKey: params.key, - signingMethod: SIGNING_METHOD, - }; - - // Store signature for FlareJS compatibility - this.transaction._signature = signature; - this.transaction.hasCredentials = true; - - return this; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_FLAREJS_SIGNING_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Build the transaction using FlareJS compatibility - */ - async build(): Promise { - // FlareJS UnsignedTx creation with atomic transaction support - try { - // Validate transaction requirements - if (!this._utxos || this._utxos.length === 0) { - throw new BuildTransactionError(ERROR_UTXOS_REQUIRED_BUILD); - } - - // Create FlareJS transaction structure with atomic support - const transaction = { - _id: `${TRANSACTION_ID_PREFIX}${Date.now()}`, - _inputs: [], - _outputs: [], - _type: this.transactionType, - signature: [] as string[], - - fromAddresses: this.transaction._fromAddresses, - validationErrors: [], - - // FlareJS methods with atomic support - toBroadcastFormat: () => `${TRANSACTION_ID_PREFIX}${Date.now()}`, - toJson: () => ({ - type: this.transactionType, - }), - - explainTransaction: (): TransactionExplanation => ({ - type: this.transactionType, - inputs: [], - outputs: [], - outputAmount: AMOUNT_STRING_ZERO, - rewardAddresses: [], - id: `${FLARE_ATOMIC_PREFIX}${Date.now()}`, - changeOutputs: [], - changeAmount: AMOUNT_STRING_ZERO, - fee: { fee: this.transaction._fee.fee }, - }), - - isTransactionForCChain: false, - loadInputsAndOutputs: () => { - /* FlareJS atomic transaction loading */ - }, - inputs: () => [], - outputs: () => [], - fee: () => ({ fee: this.transaction._fee.fee }), - feeRate: () => 0, - id: () => `${FLARE_ATOMIC_PREFIX}${Date.now()}`, - type: this.transactionType, - } as unknown as BaseTransaction; - - return transaction; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_ENHANCED_BUILD_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Parse and explain a transaction from hex using FlareJS compatibility - */ - explainTransaction(): TransactionExplanation { - // FlareJS transaction parsing with atomic support - try { - return { - type: this.transactionType, - inputs: [], - outputs: [], - outputAmount: AMOUNT_STRING_ZERO, - rewardAddresses: [], - id: `${FLARE_ATOMIC_PARSED_PREFIX}${Date.now()}`, - changeOutputs: [], - changeAmount: AMOUNT_STRING_ZERO, - fee: { fee: this.transaction._fee.fee }, - }; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_ENHANCED_PARSE_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Threshold is an int that names the number of unique signatures required to spend the output. - * Must be less than or equal to the length of Addresses. - * @param {number} - */ - threshold(value: number): this { - this.validateThreshold(value); - this.transaction._threshold = value; - return this; - } - - /** - * Validates the threshold - * @param threshold - */ - validateThreshold(threshold: number): void { - if (!threshold || threshold !== 2) { - throw new BuildTransactionError('Invalid transaction: threshold must be set to 2'); - } - } - - /** - * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output. - * @param {string | string[]} senderPubKey - */ - // TODO: check the format of the public keys - fromPubKey(senderPubKey: string | string[]): this { - const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; - this.transaction._fromAddresses = pubKeys.map(utils.parseAddress); - return this; - } - - /** - * Locktime is a long that contains the unix timestamp that this output can be spent after. - * The unix timestamp is specific to the second. - * @param value - */ - locktime(value: number): this { - const locktime = BigInt(value); - this.validateLocktime(locktime); - this.transaction._locktime = locktime; - return this; - } - - /** - * Validates locktime - * @param locktime - */ - validateLocktime(locktime: bigint): void { - if (!locktime || locktime < 0n) { - throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); - } - } } diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts new file mode 100644 index 0000000000..cecdea29da --- /dev/null +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts @@ -0,0 +1,477 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; +import { Credential, Signature, TransferableInput, TransferableOutput } from '@flarenetwork/flarejs'; +import { TransactionExplanation, DecodedUtxoObj } from './iface'; +import { + ASSET_ID_LENGTH, + TRANSACTION_ID_HEX_LENGTH, + PRIVATE_KEY_HEX_LENGTH, + SECP256K1_SIGNATURE_LENGTH, + TRANSACTION_ID_PREFIX, + DEFAULT_NETWORK_ID, + EMPTY_BUFFER_SIZE, + HEX_PREFIX, + HEX_PREFIX_LENGTH, + DECIMAL_RADIX, + SIGNING_METHOD, + AMOUNT_STRING_ZERO, + DEFAULT_LOCKTIME, + DEFAULT_THRESHOLD, + ZERO_BIGINT, + ZERO_NUMBER, + ERROR_AMOUNT_POSITIVE, + ERROR_CREDENTIALS_ARRAY, + ERROR_UTXOS_REQUIRED, + ERROR_SIGNATURES_ARRAY, + ERROR_SIGNATURES_EMPTY, + ERROR_INVALID_PRIVATE_KEY, + ERROR_UTXOS_REQUIRED_BUILD, + ERROR_ENHANCED_BUILD_FAILED, + ERROR_ENHANCED_PARSE_FAILED, + ERROR_FLAREJS_SIGNING_FAILED, + ERROR_CREATE_CREDENTIAL_FAILED, + ERROR_UNKNOWN, + FLARE_ATOMIC_PREFIX, + FLARE_ATOMIC_PARSED_PREFIX, + HEX_ENCODING, +} from './constants'; +import utils, { createFlexibleHexRegex } from './utils'; + +/** + * Flare P-chain atomic transaction builder with FlareJS credential support. + * This provides the foundation for building Flare P-chain transactions with proper + * credential handling using FlareJS Credential and Signature classes. + */ +export abstract class AtomicTransactionBuilder { + protected readonly _coinConfig: Readonly; + // External chain id (destination) for export transactions + protected _externalChainId: Buffer | undefined; + + protected _utxos: DecodedUtxoObj[] = []; + + protected transaction: { + _network: Record; + _networkID: number; + _blockchainID: Buffer; + _assetId: Buffer; + _fromAddresses: string[]; + _to: string[]; + _locktime: bigint; + _threshold: number; + _fee: { fee: string; feeRate?: string; size?: number }; + hasCredentials: boolean; + _tx?: unknown; + _signature?: unknown; + setTransaction: (tx: unknown) => void; + } = { + _network: {}, + _networkID: DEFAULT_NETWORK_ID, + _blockchainID: Buffer.alloc(EMPTY_BUFFER_SIZE), + _assetId: Buffer.alloc(EMPTY_BUFFER_SIZE), + _fromAddresses: [], + _to: [], + _locktime: DEFAULT_LOCKTIME, + _threshold: DEFAULT_THRESHOLD, + _fee: { fee: AMOUNT_STRING_ZERO }, + hasCredentials: false, + setTransaction: function (_tx: unknown) { + this._tx = _tx; + }, + }; + + constructor(coinConfig: Readonly) { + this._coinConfig = coinConfig; + } + + protected abstract get transactionType(): TransactionType; + + /** + * Get the asset ID for Flare network transactions + * @returns Buffer containing the asset ID + */ + protected getAssetId(): Buffer { + // Use the asset ID from transaction if already set + if (this.transaction._assetId && this.transaction._assetId.length > 0) { + return this.transaction._assetId; + } + + // For native FLR transactions, return zero-filled buffer as placeholder + // In a real implementation, this would be obtained from the network configuration + // or FlareJS API to get the actual native asset ID + return Buffer.alloc(ASSET_ID_LENGTH); + } + + validateAmount(amount: bigint): void { + if (amount <= ZERO_BIGINT) { + throw new BuildTransactionError(ERROR_AMOUNT_POSITIVE); + } + } + + /** + * Validates that credentials array is properly formed + * @param credentials - Array of credentials to validate + */ + protected validateCredentials(credentials: Credential[]): void { + if (!Array.isArray(credentials)) { + throw new BuildTransactionError(ERROR_CREDENTIALS_ARRAY); + } + + credentials.forEach((credential, index) => { + if (!(credential instanceof Credential)) { + throw new BuildTransactionError(`Invalid credential at index ${index}`); + } + }); + } + + /** + * Creates inputs, outputs, and credentials for Flare P-chain atomic transactions. + * Based on AVAX P-chain implementation adapted for FlareJS. + * + * Note: This is a simplified implementation that creates the core structure. + * The FlareJS type system integration will be refined in future iterations. + * + * @param total - Total amount needed including fees + * @returns Object containing TransferableInput[], TransferableOutput[], and Credential[] + */ + protected createInputOutput(total: bigint): { + inputs: TransferableInput[]; + outputs: TransferableOutput[]; + credentials: Credential[]; + } { + if (!this._utxos || this._utxos.length === ZERO_NUMBER) { + throw new BuildTransactionError(ERROR_UTXOS_REQUIRED); + } + + const inputs: TransferableInput[] = []; + const outputs: TransferableOutput[] = []; + const credentials: Credential[] = []; + + let inputSum = 0n; + const addressIndices: { [address: string]: number } = {}; + let nextAddressIndex = 0; + + // Sort UTXOs by amount in descending order for optimal coin selection + const sortedUtxos = [...this._utxos].sort((a, b) => { + const amountA = BigInt(a.amount); + const amountB = BigInt(b.amount); + if (amountA > amountB) return -1; + if (amountA < amountB) return 1; + return 0; + }); + + // Process UTXOs to create inputs and credentials + for (const utxo of sortedUtxos) { + const utxoAmount = BigInt(utxo.amount); + + if (inputSum >= total) { + break; // We have enough inputs + } + + // Track input sum + inputSum += utxoAmount; + + // Track address indices for signature ordering (mimics AVAX pattern) + const addressIndexArray: number[] = []; + for (const address of utxo.addresses) { + if (!(address in addressIndices)) { + addressIndices[address] = nextAddressIndex++; + } + addressIndexArray.push(addressIndices[address]); + } + + // Store address indices on the UTXO for credential creation + utxo.addressesIndex = addressIndexArray; + + // Create TransferableInput for atomic transactions + const transferableInput = { + txID: Buffer.from(utxo.txid || AMOUNT_STRING_ZERO.repeat(TRANSACTION_ID_HEX_LENGTH), HEX_ENCODING), + outputIndex: parseInt(utxo.outputidx || AMOUNT_STRING_ZERO, DECIMAL_RADIX), + assetID: this.getAssetId(), + input: { + amount: utxoAmount, + addressIndices: addressIndexArray, + threshold: utxo.threshold, + }, + }; + + // Store the input (type assertion for compatibility) + inputs.push(transferableInput as unknown as TransferableInput); + + // Create credential with placeholder signatures + // In a real implementation, these would be actual signatures + const signatures = Array.from({ length: utxo.threshold }, () => ''); + const credential = this.createFlareCredential(0, signatures); + credentials.push(credential); + } + + // Verify we have enough inputs + if (inputSum < total) { + throw new BuildTransactionError(`Insufficient funds: need ${total}, have ${inputSum}`); + } + + // Create change output if we have excess input amount + if (inputSum > total) { + const changeAmount = inputSum - total; + + // Create change output for atomic transactions + const changeOutput = { + assetID: this.getAssetId(), + output: { + amount: changeAmount, + addresses: this.transaction._fromAddresses, + threshold: 1, + locktime: 0n, + }, + }; + + // Add the change output (type assertion for compatibility) + outputs.push(changeOutput as unknown as TransferableOutput); + } + + return { inputs, outputs, credentials }; + } + + /** + * Set UTXOs for the transaction. This is required for creating inputs and outputs. + * + * @param utxos - Array of decoded UTXO objects + * @returns this builder instance for chaining + */ + utxos(utxos: DecodedUtxoObj[]): this { + this._utxos = utxos; + return this; + } + + /** + * Flare equivalent of Avalanche's SelectCredentialClass + * Creates a credential with the provided signatures + * + * @param credentialId - The credential ID (not used in FlareJS but kept for compatibility) + * @param signatures - Array of signature hex strings or empty strings for placeholders + * @returns Credential instance + */ + protected createFlareCredential(_credentialId: number, signatures: string[]): Credential { + if (!Array.isArray(signatures)) { + throw new BuildTransactionError(ERROR_SIGNATURES_ARRAY); + } + + if (signatures.length === ZERO_NUMBER) { + throw new BuildTransactionError(ERROR_SIGNATURES_EMPTY); + } + + const sigs = signatures.map((sig, index) => { + // Handle empty/placeholder signatures + if (!sig || sig.length === 0) { + return new Signature(new Uint8Array(SECP256K1_SIGNATURE_LENGTH)); + } + + // Validate hex string format + const cleanSig = sig.startsWith(HEX_PREFIX) ? sig.slice(HEX_PREFIX_LENGTH) : sig; + if (!createFlexibleHexRegex().test(cleanSig)) { + throw new BuildTransactionError(`Invalid hex signature at index ${index}: contains non-hex characters`); + } + + // Convert to buffer and validate length + const sigBuffer = Buffer.from(cleanSig, HEX_ENCODING); + if (sigBuffer.length > SECP256K1_SIGNATURE_LENGTH) { + throw new BuildTransactionError( + `Signature too long at index ${index}: ${sigBuffer.length} bytes (max ${SECP256K1_SIGNATURE_LENGTH})` + ); + } + + // Create fixed-length buffer and copy signature data + const fixedLengthBuffer = Buffer.alloc(SECP256K1_SIGNATURE_LENGTH); + sigBuffer.copy(fixedLengthBuffer); + + try { + return new Signature(new Uint8Array(fixedLengthBuffer)); + } catch (error) { + throw new BuildTransactionError( + `Failed to create signature at index ${index}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + }); + + try { + return new Credential(sigs); + } catch (error) { + throw new BuildTransactionError( + `${ERROR_CREATE_CREDENTIAL_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Base initBuilder used by concrete builders. For now just returns this so fluent API works. + */ + initBuilder(_tx: unknown): this { + return this; + } + + /** + * Sign transaction with private key using FlareJS compatibility + */ + sign(params: { key: string }): this { + // FlareJS signing implementation with atomic transaction support + try { + // Validate private key format (placeholder implementation) + if (!params.key || params.key.length < PRIVATE_KEY_HEX_LENGTH) { + throw new BuildTransactionError(ERROR_INVALID_PRIVATE_KEY); + } + + // Create signature structure + const signature = { + privateKey: params.key, + signingMethod: SIGNING_METHOD, + }; + + // Store signature for FlareJS compatibility + this.transaction._signature = signature; + this.transaction.hasCredentials = true; + + return this; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_FLAREJS_SIGNING_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Build the transaction using FlareJS compatibility + */ + async build(): Promise { + // FlareJS UnsignedTx creation with atomic transaction support + try { + // Validate transaction requirements + if (!this._utxos || this._utxos.length === 0) { + throw new BuildTransactionError(ERROR_UTXOS_REQUIRED_BUILD); + } + + // Create FlareJS transaction structure with atomic support + const transaction = { + _id: `${TRANSACTION_ID_PREFIX}${Date.now()}`, + _inputs: [], + _outputs: [], + _type: this.transactionType, + signature: [] as string[], + + fromAddresses: this.transaction._fromAddresses, + validationErrors: [], + + // FlareJS methods with atomic support + toBroadcastFormat: () => `${TRANSACTION_ID_PREFIX}${Date.now()}`, + toJson: () => ({ + type: this.transactionType, + }), + + explainTransaction: (): TransactionExplanation => ({ + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: AMOUNT_STRING_ZERO, + rewardAddresses: [], + id: `${FLARE_ATOMIC_PREFIX}${Date.now()}`, + changeOutputs: [], + changeAmount: AMOUNT_STRING_ZERO, + fee: { fee: this.transaction._fee.fee }, + }), + + isTransactionForCChain: false, + loadInputsAndOutputs: () => { + /* FlareJS atomic transaction loading */ + }, + inputs: () => [], + outputs: () => [], + fee: () => ({ fee: this.transaction._fee.fee }), + feeRate: () => 0, + id: () => `${FLARE_ATOMIC_PREFIX}${Date.now()}`, + type: this.transactionType, + } as unknown as BaseTransaction; + + return transaction; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_ENHANCED_BUILD_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Parse and explain a transaction from hex using FlareJS compatibility + */ + explainTransaction(): TransactionExplanation { + // FlareJS transaction parsing with atomic support + try { + return { + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: AMOUNT_STRING_ZERO, + rewardAddresses: [], + id: `${FLARE_ATOMIC_PARSED_PREFIX}${Date.now()}`, + changeOutputs: [], + changeAmount: AMOUNT_STRING_ZERO, + fee: { fee: this.transaction._fee.fee }, + }; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_ENHANCED_PARSE_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Threshold is an int that names the number of unique signatures required to spend the output. + * Must be less than or equal to the length of Addresses. + * @param {number} + */ + threshold(value: number): this { + this.validateThreshold(value); + this.transaction._threshold = value; + return this; + } + + /** + * Validates the threshold + * @param threshold + */ + validateThreshold(threshold: number): void { + if (!threshold || threshold !== 2) { + throw new BuildTransactionError('Invalid transaction: threshold must be set to 2'); + } + } + + /** + * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output. + * @param {string | string[]} senderPubKey + */ + // TODO: check the format of the public keys + fromPubKey(senderPubKey: string | string[]): this { + const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; + this.transaction._fromAddresses = pubKeys.map(utils.parseAddress); + return this; + } + + /** + * Locktime is a long that contains the unix timestamp that this output can be spent after. + * The unix timestamp is specific to the second. + * @param value + */ + locktime(value: number): this { + const locktime = BigInt(value); + this.validateLocktime(locktime); + this.transaction._locktime = locktime; + return this; + } + + /** + * Validates locktime + * @param locktime + */ + validateLocktime(locktime: bigint): void { + if (!locktime || locktime < 0n) { + throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); + } + } +} diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts index d791ec199e..f7a31ad754 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts @@ -1,4 +1,10 @@ -import { BaseTransactionBuilder, BuildTransactionError } from '@bitgo/sdk-core'; +import { + BaseTransactionBuilder, + BuildTransactionError, + TransactionType, + BaseKey, + BaseTransaction, +} from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { DecodedUtxoObj, Tx } from './iface'; import { KeyPair } from './keyPair'; @@ -180,6 +186,37 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.buildFlareTransaction(); + this.transaction.setTransactionType(this.transactionType); + if (this.hasSigner) { + this._signer.forEach((keyPair) => this.transaction.sign(keyPair)); + } + return this.transaction; + } + + protected abstract get transactionType(): TransactionType; + + /** + * Getter for know if build should sign + */ + get hasSigner(): boolean { + return this._signer !== undefined && this._signer.length > 0; + } + + /** + * Build the Flare transaction using FlareJS API + * @protected + */ + protected abstract buildFlareTransaction(): Promise | void; + + /** @inheritdoc */ + protected signImplementation({ key }: BaseKey): BaseTransaction { + this._signer.push(new KeyPair({ prv: key })); + return this.transaction; + } + /** * Get the transaction instance */ diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 2d286f0d06..dd4343e54b 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -15,7 +15,7 @@ export interface FlareNetwork extends BaseNetwork { hrp: string; alias: string; vm?: string; - txFee?: string; + txFee: string; maxImportFee?: string; createSubnetTx?: string; createChainTx?: string; From bba5e02394cbc6f205ee18adeff45b81539ab3bd Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 11 Nov 2025 16:25:57 +0530 Subject: [PATCH 7/7] refactor(networks): make blockchainID and cChainBlockchainID mandatory in FlareNetwork interface TICKET: WIN-7770 --- .../src/lib/atomicInCTransactionBuilder.ts | 2 +- .../atomicInCTransactionBuilderDeprecated.ts | 74 --- .../src/lib/atomicTransactionBuilder.ts | 306 +++++++++-- .../lib/atomicTransactionBuilderDeprecated.ts | 477 ------------------ modules/statics/src/networks.ts | 4 +- 5 files changed, 268 insertions(+), 595 deletions(-) delete mode 100644 modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts delete mode 100644 modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts diff --git a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts index 91025c8c15..7f26bd836d 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilder.ts @@ -1,4 +1,4 @@ -import { AtomicTransactionBuilder } from './atomicTransactionBuilderDeprecated'; +import { AtomicTransactionBuilder } from './atomicTransactionBuilder'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import utils from './utils'; import { BuildTransactionError } from '@bitgo/sdk-core'; diff --git a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts b/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts deleted file mode 100644 index 91025c8c15..0000000000 --- a/modules/sdk-coin-flrp/src/lib/atomicInCTransactionBuilderDeprecated.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AtomicTransactionBuilder } from './atomicTransactionBuilderDeprecated'; -import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import utils from './utils'; -import { BuildTransactionError } from '@bitgo/sdk-core'; - -interface FlareChainNetworkMeta { - blockchainID?: string; // P-chain id (external) - cChainBlockchainID?: string; // C-chain id (local) - [k: string]: unknown; -} - -interface FeeShape { - fee?: string; // legacy - feeRate?: string; // per unit rate -} - -/** - * Flare P->C atomic import/export style builder (C-chain context). This adapts the AVAXP logic - * removing direct Avalanche SDK dependencies. Network / chain ids are expected to be provided - * in the transaction._network object by a higher-level factory once Flare network constants - * are finalized. For now we CB58-decode placeholders if present and default to zero buffers. - */ -export abstract class AtomicInCTransactionBuilder extends AtomicTransactionBuilder { - // Placeholder fixed fee (can be overridden by subclasses or network config) - protected fixedFee = 0n; - constructor(_coinConfig: Readonly) { - super(_coinConfig); - this.initializeChainIds(); - } - - /** - * Set base fee (already scaled to Flare C-chain native decimals). Accept bigint | number | string. - */ - feeRate(baseFee: bigint | number | string): this { - const n = typeof baseFee === 'bigint' ? baseFee : BigInt(baseFee); - this.validateFee(n); - this.setFeeRate(n); - return this; - } - - /** - * Recreate builder state from raw tx (hex). Flare C-chain support TBD; for now validate & stash. - */ - protected fromImplementation(rawTransaction: string): { _tx?: unknown } { - // If utils has validateRawTransaction use it; otherwise basic check - if ((utils as unknown as { validateRawTransaction?: (r: string) => void }).validateRawTransaction) { - (utils as unknown as { validateRawTransaction: (r: string) => void }).validateRawTransaction(rawTransaction); - } - this.transaction.setTransaction(rawTransaction); - return this.transaction; - } - - private validateFee(fee: bigint): void { - if (fee <= 0n) { - throw new BuildTransactionError('Fee must be greater than 0'); - } - } - - private initializeChainIds(): void { - const meta = this.transaction._network as FlareChainNetworkMeta; - if (meta?.blockchainID) { - this._externalChainId = utils.cb58Decode(meta.blockchainID); - } - if (meta?.cChainBlockchainID) { - this.transaction._blockchainID = utils.cb58Decode(meta.cChainBlockchainID); - } - } - - private setFeeRate(n: bigint): void { - const currentContainer = this.transaction as unknown as { _fee: FeeShape }; - const current = currentContainer._fee || { fee: '0' }; - currentContainer._fee = { ...current, feeRate: n.toString() }; - } -} diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 7e5a8a4a5b..cecdea29da 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -1,71 +1,126 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BuildTransactionError } from '@bitgo/sdk-core'; +import { BuildTransactionError, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; import { Credential, Signature, TransferableInput, TransferableOutput } from '@flarenetwork/flarejs'; -import { DecodedUtxoObj } from './iface'; +import { TransactionExplanation, DecodedUtxoObj } from './iface'; import { ASSET_ID_LENGTH, TRANSACTION_ID_HEX_LENGTH, + PRIVATE_KEY_HEX_LENGTH, SECP256K1_SIGNATURE_LENGTH, + TRANSACTION_ID_PREFIX, + DEFAULT_NETWORK_ID, + EMPTY_BUFFER_SIZE, HEX_PREFIX, HEX_PREFIX_LENGTH, DECIMAL_RADIX, + SIGNING_METHOD, AMOUNT_STRING_ZERO, + DEFAULT_LOCKTIME, + DEFAULT_THRESHOLD, + ZERO_BIGINT, ZERO_NUMBER, + ERROR_AMOUNT_POSITIVE, + ERROR_CREDENTIALS_ARRAY, ERROR_UTXOS_REQUIRED, ERROR_SIGNATURES_ARRAY, ERROR_SIGNATURES_EMPTY, + ERROR_INVALID_PRIVATE_KEY, + ERROR_UTXOS_REQUIRED_BUILD, + ERROR_ENHANCED_BUILD_FAILED, + ERROR_ENHANCED_PARSE_FAILED, + ERROR_FLAREJS_SIGNING_FAILED, ERROR_CREATE_CREDENTIAL_FAILED, ERROR_UNKNOWN, + FLARE_ATOMIC_PREFIX, + FLARE_ATOMIC_PARSED_PREFIX, HEX_ENCODING, } from './constants'; import utils, { createFlexibleHexRegex } from './utils'; -import { TransactionBuilder } from './transactionBuilder'; /** * Flare P-chain atomic transaction builder with FlareJS credential support. * This provides the foundation for building Flare P-chain transactions with proper * credential handling using FlareJS Credential and Signature classes. */ -export abstract class AtomicTransactionBuilder extends TransactionBuilder { +export abstract class AtomicTransactionBuilder { + protected readonly _coinConfig: Readonly; + // External chain id (destination) for export transactions protected _externalChainId: Buffer | undefined; + protected _utxos: DecodedUtxoObj[] = []; - constructor(_coinConfig: Readonly) { - super(_coinConfig); - this.transaction._fee.fee = this.fixedFee; + protected transaction: { + _network: Record; + _networkID: number; + _blockchainID: Buffer; + _assetId: Buffer; + _fromAddresses: string[]; + _to: string[]; + _locktime: bigint; + _threshold: number; + _fee: { fee: string; feeRate?: string; size?: number }; + hasCredentials: boolean; + _tx?: unknown; + _signature?: unknown; + setTransaction: (tx: unknown) => void; + } = { + _network: {}, + _networkID: DEFAULT_NETWORK_ID, + _blockchainID: Buffer.alloc(EMPTY_BUFFER_SIZE), + _assetId: Buffer.alloc(EMPTY_BUFFER_SIZE), + _fromAddresses: [], + _to: [], + _locktime: DEFAULT_LOCKTIME, + _threshold: DEFAULT_THRESHOLD, + _fee: { fee: AMOUNT_STRING_ZERO }, + hasCredentials: false, + setTransaction: function (_tx: unknown) { + this._tx = _tx; + }, + }; + + constructor(coinConfig: Readonly) { + this._coinConfig = coinConfig; } + protected abstract get transactionType(): TransactionType; + /** - * The internal chain is the one set for the coin in coinConfig.network. The external chain is the other chain involved. - * The external chain id is the source on import and the destination on export. - * - * @param {string} chainId - id of the external chain + * Get the asset ID for Flare network transactions + * @returns Buffer containing the asset ID */ - externalChainId(chainId: string | Buffer): this { - const newTargetChainId = typeof chainId === 'string' ? utils.cb58Decode(chainId) : Buffer.from(chainId); - this.validateChainId(newTargetChainId); - this._externalChainId = newTargetChainId; - return this; + protected getAssetId(): Buffer { + // Use the asset ID from transaction if already set + if (this.transaction._assetId && this.transaction._assetId.length > 0) { + return this.transaction._assetId; + } + + // For native FLR transactions, return zero-filled buffer as placeholder + // In a real implementation, this would be obtained from the network configuration + // or FlareJS API to get the actual native asset ID + return Buffer.alloc(ASSET_ID_LENGTH); } - /** - * Fee is fix for AVM atomic tx. - * - * @returns network.txFee - * @protected - */ - protected get fixedFee(): string { - return this.transaction._network.txFee; + validateAmount(amount: bigint): void { + if (amount <= ZERO_BIGINT) { + throw new BuildTransactionError(ERROR_AMOUNT_POSITIVE); + } } /** - * Check the buffer has 32 byte long. - * @param chainID + * Validates that credentials array is properly formed + * @param credentials - Array of credentials to validate */ - validateChainId(chainID: Buffer): void { - if (chainID.length !== 32) { - throw new BuildTransactionError('Chain id are 32 byte size'); + protected validateCredentials(credentials: Credential[]): void { + if (!Array.isArray(credentials)) { + throw new BuildTransactionError(ERROR_CREDENTIALS_ARRAY); } + + credentials.forEach((credential, index) => { + if (!(credential instanceof Credential)) { + throw new BuildTransactionError(`Invalid credential at index ${index}`); + } + }); } /** @@ -177,19 +232,14 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { } /** - * Get the asset ID for Flare network transactions - * @returns Buffer containing the asset ID + * Set UTXOs for the transaction. This is required for creating inputs and outputs. + * + * @param utxos - Array of decoded UTXO objects + * @returns this builder instance for chaining */ - protected getAssetId(): Buffer { - // Use the asset ID from transaction if already set - if (this.transaction._assetId && this.transaction._assetId.length > 0) { - return Buffer.from(this.transaction._assetId); - } - - // For native FLR transactions, return zero-filled buffer as placeholder - // In a real implementation, this would be obtained from the network configuration - // or FlareJS API to get the actual native asset ID - return Buffer.alloc(ASSET_ID_LENGTH); + utxos(utxos: DecodedUtxoObj[]): this { + this._utxos = utxos; + return this; } /** @@ -250,4 +300,178 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { ); } } + + /** + * Base initBuilder used by concrete builders. For now just returns this so fluent API works. + */ + initBuilder(_tx: unknown): this { + return this; + } + + /** + * Sign transaction with private key using FlareJS compatibility + */ + sign(params: { key: string }): this { + // FlareJS signing implementation with atomic transaction support + try { + // Validate private key format (placeholder implementation) + if (!params.key || params.key.length < PRIVATE_KEY_HEX_LENGTH) { + throw new BuildTransactionError(ERROR_INVALID_PRIVATE_KEY); + } + + // Create signature structure + const signature = { + privateKey: params.key, + signingMethod: SIGNING_METHOD, + }; + + // Store signature for FlareJS compatibility + this.transaction._signature = signature; + this.transaction.hasCredentials = true; + + return this; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_FLAREJS_SIGNING_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Build the transaction using FlareJS compatibility + */ + async build(): Promise { + // FlareJS UnsignedTx creation with atomic transaction support + try { + // Validate transaction requirements + if (!this._utxos || this._utxos.length === 0) { + throw new BuildTransactionError(ERROR_UTXOS_REQUIRED_BUILD); + } + + // Create FlareJS transaction structure with atomic support + const transaction = { + _id: `${TRANSACTION_ID_PREFIX}${Date.now()}`, + _inputs: [], + _outputs: [], + _type: this.transactionType, + signature: [] as string[], + + fromAddresses: this.transaction._fromAddresses, + validationErrors: [], + + // FlareJS methods with atomic support + toBroadcastFormat: () => `${TRANSACTION_ID_PREFIX}${Date.now()}`, + toJson: () => ({ + type: this.transactionType, + }), + + explainTransaction: (): TransactionExplanation => ({ + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: AMOUNT_STRING_ZERO, + rewardAddresses: [], + id: `${FLARE_ATOMIC_PREFIX}${Date.now()}`, + changeOutputs: [], + changeAmount: AMOUNT_STRING_ZERO, + fee: { fee: this.transaction._fee.fee }, + }), + + isTransactionForCChain: false, + loadInputsAndOutputs: () => { + /* FlareJS atomic transaction loading */ + }, + inputs: () => [], + outputs: () => [], + fee: () => ({ fee: this.transaction._fee.fee }), + feeRate: () => 0, + id: () => `${FLARE_ATOMIC_PREFIX}${Date.now()}`, + type: this.transactionType, + } as unknown as BaseTransaction; + + return transaction; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_ENHANCED_BUILD_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Parse and explain a transaction from hex using FlareJS compatibility + */ + explainTransaction(): TransactionExplanation { + // FlareJS transaction parsing with atomic support + try { + return { + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: AMOUNT_STRING_ZERO, + rewardAddresses: [], + id: `${FLARE_ATOMIC_PARSED_PREFIX}${Date.now()}`, + changeOutputs: [], + changeAmount: AMOUNT_STRING_ZERO, + fee: { fee: this.transaction._fee.fee }, + }; + } catch (error) { + throw new BuildTransactionError( + `${ERROR_ENHANCED_PARSE_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` + ); + } + } + + /** + * Threshold is an int that names the number of unique signatures required to spend the output. + * Must be less than or equal to the length of Addresses. + * @param {number} + */ + threshold(value: number): this { + this.validateThreshold(value); + this.transaction._threshold = value; + return this; + } + + /** + * Validates the threshold + * @param threshold + */ + validateThreshold(threshold: number): void { + if (!threshold || threshold !== 2) { + throw new BuildTransactionError('Invalid transaction: threshold must be set to 2'); + } + } + + /** + * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output. + * @param {string | string[]} senderPubKey + */ + // TODO: check the format of the public keys + fromPubKey(senderPubKey: string | string[]): this { + const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; + this.transaction._fromAddresses = pubKeys.map(utils.parseAddress); + return this; + } + + /** + * Locktime is a long that contains the unix timestamp that this output can be spent after. + * The unix timestamp is specific to the second. + * @param value + */ + locktime(value: number): this { + const locktime = BigInt(value); + this.validateLocktime(locktime); + this.transaction._locktime = locktime; + return this; + } + + /** + * Validates locktime + * @param locktime + */ + validateLocktime(locktime: bigint): void { + if (!locktime || locktime < 0n) { + throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); + } + } } diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts deleted file mode 100644 index cecdea29da..0000000000 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilderDeprecated.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BuildTransactionError, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; -import { Credential, Signature, TransferableInput, TransferableOutput } from '@flarenetwork/flarejs'; -import { TransactionExplanation, DecodedUtxoObj } from './iface'; -import { - ASSET_ID_LENGTH, - TRANSACTION_ID_HEX_LENGTH, - PRIVATE_KEY_HEX_LENGTH, - SECP256K1_SIGNATURE_LENGTH, - TRANSACTION_ID_PREFIX, - DEFAULT_NETWORK_ID, - EMPTY_BUFFER_SIZE, - HEX_PREFIX, - HEX_PREFIX_LENGTH, - DECIMAL_RADIX, - SIGNING_METHOD, - AMOUNT_STRING_ZERO, - DEFAULT_LOCKTIME, - DEFAULT_THRESHOLD, - ZERO_BIGINT, - ZERO_NUMBER, - ERROR_AMOUNT_POSITIVE, - ERROR_CREDENTIALS_ARRAY, - ERROR_UTXOS_REQUIRED, - ERROR_SIGNATURES_ARRAY, - ERROR_SIGNATURES_EMPTY, - ERROR_INVALID_PRIVATE_KEY, - ERROR_UTXOS_REQUIRED_BUILD, - ERROR_ENHANCED_BUILD_FAILED, - ERROR_ENHANCED_PARSE_FAILED, - ERROR_FLAREJS_SIGNING_FAILED, - ERROR_CREATE_CREDENTIAL_FAILED, - ERROR_UNKNOWN, - FLARE_ATOMIC_PREFIX, - FLARE_ATOMIC_PARSED_PREFIX, - HEX_ENCODING, -} from './constants'; -import utils, { createFlexibleHexRegex } from './utils'; - -/** - * Flare P-chain atomic transaction builder with FlareJS credential support. - * This provides the foundation for building Flare P-chain transactions with proper - * credential handling using FlareJS Credential and Signature classes. - */ -export abstract class AtomicTransactionBuilder { - protected readonly _coinConfig: Readonly; - // External chain id (destination) for export transactions - protected _externalChainId: Buffer | undefined; - - protected _utxos: DecodedUtxoObj[] = []; - - protected transaction: { - _network: Record; - _networkID: number; - _blockchainID: Buffer; - _assetId: Buffer; - _fromAddresses: string[]; - _to: string[]; - _locktime: bigint; - _threshold: number; - _fee: { fee: string; feeRate?: string; size?: number }; - hasCredentials: boolean; - _tx?: unknown; - _signature?: unknown; - setTransaction: (tx: unknown) => void; - } = { - _network: {}, - _networkID: DEFAULT_NETWORK_ID, - _blockchainID: Buffer.alloc(EMPTY_BUFFER_SIZE), - _assetId: Buffer.alloc(EMPTY_BUFFER_SIZE), - _fromAddresses: [], - _to: [], - _locktime: DEFAULT_LOCKTIME, - _threshold: DEFAULT_THRESHOLD, - _fee: { fee: AMOUNT_STRING_ZERO }, - hasCredentials: false, - setTransaction: function (_tx: unknown) { - this._tx = _tx; - }, - }; - - constructor(coinConfig: Readonly) { - this._coinConfig = coinConfig; - } - - protected abstract get transactionType(): TransactionType; - - /** - * Get the asset ID for Flare network transactions - * @returns Buffer containing the asset ID - */ - protected getAssetId(): Buffer { - // Use the asset ID from transaction if already set - if (this.transaction._assetId && this.transaction._assetId.length > 0) { - return this.transaction._assetId; - } - - // For native FLR transactions, return zero-filled buffer as placeholder - // In a real implementation, this would be obtained from the network configuration - // or FlareJS API to get the actual native asset ID - return Buffer.alloc(ASSET_ID_LENGTH); - } - - validateAmount(amount: bigint): void { - if (amount <= ZERO_BIGINT) { - throw new BuildTransactionError(ERROR_AMOUNT_POSITIVE); - } - } - - /** - * Validates that credentials array is properly formed - * @param credentials - Array of credentials to validate - */ - protected validateCredentials(credentials: Credential[]): void { - if (!Array.isArray(credentials)) { - throw new BuildTransactionError(ERROR_CREDENTIALS_ARRAY); - } - - credentials.forEach((credential, index) => { - if (!(credential instanceof Credential)) { - throw new BuildTransactionError(`Invalid credential at index ${index}`); - } - }); - } - - /** - * Creates inputs, outputs, and credentials for Flare P-chain atomic transactions. - * Based on AVAX P-chain implementation adapted for FlareJS. - * - * Note: This is a simplified implementation that creates the core structure. - * The FlareJS type system integration will be refined in future iterations. - * - * @param total - Total amount needed including fees - * @returns Object containing TransferableInput[], TransferableOutput[], and Credential[] - */ - protected createInputOutput(total: bigint): { - inputs: TransferableInput[]; - outputs: TransferableOutput[]; - credentials: Credential[]; - } { - if (!this._utxos || this._utxos.length === ZERO_NUMBER) { - throw new BuildTransactionError(ERROR_UTXOS_REQUIRED); - } - - const inputs: TransferableInput[] = []; - const outputs: TransferableOutput[] = []; - const credentials: Credential[] = []; - - let inputSum = 0n; - const addressIndices: { [address: string]: number } = {}; - let nextAddressIndex = 0; - - // Sort UTXOs by amount in descending order for optimal coin selection - const sortedUtxos = [...this._utxos].sort((a, b) => { - const amountA = BigInt(a.amount); - const amountB = BigInt(b.amount); - if (amountA > amountB) return -1; - if (amountA < amountB) return 1; - return 0; - }); - - // Process UTXOs to create inputs and credentials - for (const utxo of sortedUtxos) { - const utxoAmount = BigInt(utxo.amount); - - if (inputSum >= total) { - break; // We have enough inputs - } - - // Track input sum - inputSum += utxoAmount; - - // Track address indices for signature ordering (mimics AVAX pattern) - const addressIndexArray: number[] = []; - for (const address of utxo.addresses) { - if (!(address in addressIndices)) { - addressIndices[address] = nextAddressIndex++; - } - addressIndexArray.push(addressIndices[address]); - } - - // Store address indices on the UTXO for credential creation - utxo.addressesIndex = addressIndexArray; - - // Create TransferableInput for atomic transactions - const transferableInput = { - txID: Buffer.from(utxo.txid || AMOUNT_STRING_ZERO.repeat(TRANSACTION_ID_HEX_LENGTH), HEX_ENCODING), - outputIndex: parseInt(utxo.outputidx || AMOUNT_STRING_ZERO, DECIMAL_RADIX), - assetID: this.getAssetId(), - input: { - amount: utxoAmount, - addressIndices: addressIndexArray, - threshold: utxo.threshold, - }, - }; - - // Store the input (type assertion for compatibility) - inputs.push(transferableInput as unknown as TransferableInput); - - // Create credential with placeholder signatures - // In a real implementation, these would be actual signatures - const signatures = Array.from({ length: utxo.threshold }, () => ''); - const credential = this.createFlareCredential(0, signatures); - credentials.push(credential); - } - - // Verify we have enough inputs - if (inputSum < total) { - throw new BuildTransactionError(`Insufficient funds: need ${total}, have ${inputSum}`); - } - - // Create change output if we have excess input amount - if (inputSum > total) { - const changeAmount = inputSum - total; - - // Create change output for atomic transactions - const changeOutput = { - assetID: this.getAssetId(), - output: { - amount: changeAmount, - addresses: this.transaction._fromAddresses, - threshold: 1, - locktime: 0n, - }, - }; - - // Add the change output (type assertion for compatibility) - outputs.push(changeOutput as unknown as TransferableOutput); - } - - return { inputs, outputs, credentials }; - } - - /** - * Set UTXOs for the transaction. This is required for creating inputs and outputs. - * - * @param utxos - Array of decoded UTXO objects - * @returns this builder instance for chaining - */ - utxos(utxos: DecodedUtxoObj[]): this { - this._utxos = utxos; - return this; - } - - /** - * Flare equivalent of Avalanche's SelectCredentialClass - * Creates a credential with the provided signatures - * - * @param credentialId - The credential ID (not used in FlareJS but kept for compatibility) - * @param signatures - Array of signature hex strings or empty strings for placeholders - * @returns Credential instance - */ - protected createFlareCredential(_credentialId: number, signatures: string[]): Credential { - if (!Array.isArray(signatures)) { - throw new BuildTransactionError(ERROR_SIGNATURES_ARRAY); - } - - if (signatures.length === ZERO_NUMBER) { - throw new BuildTransactionError(ERROR_SIGNATURES_EMPTY); - } - - const sigs = signatures.map((sig, index) => { - // Handle empty/placeholder signatures - if (!sig || sig.length === 0) { - return new Signature(new Uint8Array(SECP256K1_SIGNATURE_LENGTH)); - } - - // Validate hex string format - const cleanSig = sig.startsWith(HEX_PREFIX) ? sig.slice(HEX_PREFIX_LENGTH) : sig; - if (!createFlexibleHexRegex().test(cleanSig)) { - throw new BuildTransactionError(`Invalid hex signature at index ${index}: contains non-hex characters`); - } - - // Convert to buffer and validate length - const sigBuffer = Buffer.from(cleanSig, HEX_ENCODING); - if (sigBuffer.length > SECP256K1_SIGNATURE_LENGTH) { - throw new BuildTransactionError( - `Signature too long at index ${index}: ${sigBuffer.length} bytes (max ${SECP256K1_SIGNATURE_LENGTH})` - ); - } - - // Create fixed-length buffer and copy signature data - const fixedLengthBuffer = Buffer.alloc(SECP256K1_SIGNATURE_LENGTH); - sigBuffer.copy(fixedLengthBuffer); - - try { - return new Signature(new Uint8Array(fixedLengthBuffer)); - } catch (error) { - throw new BuildTransactionError( - `Failed to create signature at index ${index}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - }); - - try { - return new Credential(sigs); - } catch (error) { - throw new BuildTransactionError( - `${ERROR_CREATE_CREDENTIAL_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Base initBuilder used by concrete builders. For now just returns this so fluent API works. - */ - initBuilder(_tx: unknown): this { - return this; - } - - /** - * Sign transaction with private key using FlareJS compatibility - */ - sign(params: { key: string }): this { - // FlareJS signing implementation with atomic transaction support - try { - // Validate private key format (placeholder implementation) - if (!params.key || params.key.length < PRIVATE_KEY_HEX_LENGTH) { - throw new BuildTransactionError(ERROR_INVALID_PRIVATE_KEY); - } - - // Create signature structure - const signature = { - privateKey: params.key, - signingMethod: SIGNING_METHOD, - }; - - // Store signature for FlareJS compatibility - this.transaction._signature = signature; - this.transaction.hasCredentials = true; - - return this; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_FLAREJS_SIGNING_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Build the transaction using FlareJS compatibility - */ - async build(): Promise { - // FlareJS UnsignedTx creation with atomic transaction support - try { - // Validate transaction requirements - if (!this._utxos || this._utxos.length === 0) { - throw new BuildTransactionError(ERROR_UTXOS_REQUIRED_BUILD); - } - - // Create FlareJS transaction structure with atomic support - const transaction = { - _id: `${TRANSACTION_ID_PREFIX}${Date.now()}`, - _inputs: [], - _outputs: [], - _type: this.transactionType, - signature: [] as string[], - - fromAddresses: this.transaction._fromAddresses, - validationErrors: [], - - // FlareJS methods with atomic support - toBroadcastFormat: () => `${TRANSACTION_ID_PREFIX}${Date.now()}`, - toJson: () => ({ - type: this.transactionType, - }), - - explainTransaction: (): TransactionExplanation => ({ - type: this.transactionType, - inputs: [], - outputs: [], - outputAmount: AMOUNT_STRING_ZERO, - rewardAddresses: [], - id: `${FLARE_ATOMIC_PREFIX}${Date.now()}`, - changeOutputs: [], - changeAmount: AMOUNT_STRING_ZERO, - fee: { fee: this.transaction._fee.fee }, - }), - - isTransactionForCChain: false, - loadInputsAndOutputs: () => { - /* FlareJS atomic transaction loading */ - }, - inputs: () => [], - outputs: () => [], - fee: () => ({ fee: this.transaction._fee.fee }), - feeRate: () => 0, - id: () => `${FLARE_ATOMIC_PREFIX}${Date.now()}`, - type: this.transactionType, - } as unknown as BaseTransaction; - - return transaction; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_ENHANCED_BUILD_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Parse and explain a transaction from hex using FlareJS compatibility - */ - explainTransaction(): TransactionExplanation { - // FlareJS transaction parsing with atomic support - try { - return { - type: this.transactionType, - inputs: [], - outputs: [], - outputAmount: AMOUNT_STRING_ZERO, - rewardAddresses: [], - id: `${FLARE_ATOMIC_PARSED_PREFIX}${Date.now()}`, - changeOutputs: [], - changeAmount: AMOUNT_STRING_ZERO, - fee: { fee: this.transaction._fee.fee }, - }; - } catch (error) { - throw new BuildTransactionError( - `${ERROR_ENHANCED_PARSE_FAILED}: ${error instanceof Error ? error.message : ERROR_UNKNOWN}` - ); - } - } - - /** - * Threshold is an int that names the number of unique signatures required to spend the output. - * Must be less than or equal to the length of Addresses. - * @param {number} - */ - threshold(value: number): this { - this.validateThreshold(value); - this.transaction._threshold = value; - return this; - } - - /** - * Validates the threshold - * @param threshold - */ - validateThreshold(threshold: number): void { - if (!threshold || threshold !== 2) { - throw new BuildTransactionError('Invalid transaction: threshold must be set to 2'); - } - } - - /** - * fromPubKey is a list of unique addresses that correspond to the private keys that can be used to spend this output. - * @param {string | string[]} senderPubKey - */ - // TODO: check the format of the public keys - fromPubKey(senderPubKey: string | string[]): this { - const pubKeys = senderPubKey instanceof Array ? senderPubKey : [senderPubKey]; - this.transaction._fromAddresses = pubKeys.map(utils.parseAddress); - return this; - } - - /** - * Locktime is a long that contains the unix timestamp that this output can be spent after. - * The unix timestamp is specific to the second. - * @param value - */ - locktime(value: number): this { - const locktime = BigInt(value); - this.validateLocktime(locktime); - this.transaction._locktime = locktime; - return this; - } - - /** - * Validates locktime - * @param locktime - */ - validateLocktime(locktime: bigint): void { - if (!locktime || locktime < 0n) { - throw new BuildTransactionError('Invalid transaction: locktime must be 0 or higher'); - } - } -} diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index dd4343e54b..d5ef5b8b6d 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -9,8 +9,8 @@ export interface FlareNetwork extends BaseNetwork { batcherContractAddress?: string; forwarderFactoryAddress?: string; forwarderImplementationAddress?: string; - blockchainID?: string; - cChainBlockchainID?: string; + blockchainID: string; + cChainBlockchainID: string; networkID?: number; hrp: string; alias: string;