From 876c744cdc3615a3fdcfb9d96d548f56eacabc1a Mon Sep 17 00:00:00 2001 From: ruijialin Date: Wed, 9 Oct 2024 15:23:43 -0400 Subject: [PATCH] feat: add validateAvaxBurnedAmountEtna --- examples/p-chain/etna/utils/etna-context.ts | 9 +- src/fixtures/info.ts | 16 ++ src/utils/index.ts | 1 + src/utils/isEtnaEnabled.ts | 8 + .../validateAvaxBurnedAmountEtna.test.ts | 265 ++++++++++++++++++ src/utils/validateAvaxBurnedAmountEtna.ts | 58 ++++ ...> validateAvaxBurnedAmountPreEtna.test.ts} | 204 +------------- src/utils/validateAvaxBurnedAmountPreEtna.ts | 102 +++++++ src/utils/validateBurnedAmount.ts | 149 ++++------ src/utils/validateEvmBurnedAmount.test.ts | 164 +++++++++++ src/utils/validateEvmBurnedAmount.ts | 39 +++ 11 files changed, 710 insertions(+), 305 deletions(-) create mode 100644 src/fixtures/info.ts create mode 100644 src/utils/isEtnaEnabled.ts create mode 100644 src/utils/validateAvaxBurnedAmountEtna.test.ts create mode 100644 src/utils/validateAvaxBurnedAmountEtna.ts rename src/utils/{validateBurnedAmount.test.ts => validateAvaxBurnedAmountPreEtna.test.ts} (59%) create mode 100644 src/utils/validateAvaxBurnedAmountPreEtna.ts create mode 100644 src/utils/validateEvmBurnedAmount.test.ts create mode 100644 src/utils/validateEvmBurnedAmount.ts diff --git a/examples/p-chain/etna/utils/etna-context.ts b/examples/p-chain/etna/utils/etna-context.ts index 705cd3323..99dde0963 100644 --- a/examples/p-chain/etna/utils/etna-context.ts +++ b/examples/p-chain/etna/utils/etna-context.ts @@ -1,4 +1,5 @@ import { Context, Info } from '../../../../src'; +import { isEtnaEnabled } from '../../../../src/utils'; /** * Gets the context from URI and then modifies the context * to be used for testing example Etna transactions until Etna is enabled. @@ -10,12 +11,10 @@ export const getEtnaContextFromURI = async ( const info = new Info(uri); - const { etnaTime } = await info.getUpgradesInfo(); + const upgradesInfo = await info.getUpgradesInfo(); + const enabled = isEtnaEnabled(upgradesInfo); - const etnaDateTime = new Date(etnaTime); - const now = new Date(); - - if (etnaDateTime < now) { + if (enabled) { return context; } diff --git a/src/fixtures/info.ts b/src/fixtures/info.ts new file mode 100644 index 000000000..149856066 --- /dev/null +++ b/src/fixtures/info.ts @@ -0,0 +1,16 @@ +export const upgradesInfo = { + apricotPhaselTime: '2020-12-05T05:00:00Z', + apricotPhase2Time: '2020-12-05T05:00:00Z', + apricotPhase3Time: '2020-12-05T05:00:00Z', + apricotPhase4Time: '2020-12-05T05:00:00Z', + apricotPhase4MinPChainHeight: 0, + apricotPhase5Time: '2020-12-05T05:00:00Z', + apricotPhasePre6Time: '2020-12-05T05:00:00Z', + apricotPhase6Time: '2020-12-05T05:00:00Z', + apricotPhasePost6Time: '2020-12-05T05:00:00Z', + banffTime: '2020-12-05T05:00:00Z', + cortinaTime: '2020-12-05T05:00:00Z', + cortinaXChainStopVertexID: '11111111111111111111111111111111LpoYY', + durangoTime: '2020-12-05T05:00:00Z', + etnaTime: '2020-12-05T05:00:00Z', +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index b36f58155..cbdc6d184 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,4 +14,5 @@ export * from './getTransferableOutputsByTx'; export * from './getUtxoInfo'; export * from './getBurnedAmountByTx'; export * from './validateBurnedAmount'; +export * from './isEtnaEnabled'; export { unpackWithManager, getManagerForVM, packTx } from './packTx'; diff --git a/src/utils/isEtnaEnabled.ts b/src/utils/isEtnaEnabled.ts new file mode 100644 index 000000000..3762b98dc --- /dev/null +++ b/src/utils/isEtnaEnabled.ts @@ -0,0 +1,8 @@ +import type { GetUpgradesInfoResponse } from '../info/model'; + +export const isEtnaEnabled = ( + upgradesInfo: GetUpgradesInfoResponse, +): boolean => { + const { etnaTime } = upgradesInfo; + return new Date(etnaTime) < new Date(); +}; diff --git a/src/utils/validateAvaxBurnedAmountEtna.test.ts b/src/utils/validateAvaxBurnedAmountEtna.test.ts new file mode 100644 index 000000000..4909c62cc --- /dev/null +++ b/src/utils/validateAvaxBurnedAmountEtna.test.ts @@ -0,0 +1,265 @@ +import { testAddress1, testAddress2 } from '../fixtures/vms'; +import { testContext } from '../fixtures/context'; +import { Utxo } from '../serializable/avax/utxo'; +import { utxoId } from '../fixtures/avax'; +import { Address, Id } from '../serializable/fxs/common'; +import { OutputOwners, TransferOutput } from '../serializable/fxs/secp256k1'; +import { BigIntPr, Int } from '../serializable/primitives'; +import { + newBaseTx as avmBaseTx, + newExportTx as avmExportTx, + newImportTx as avmImportTx, +} from '../vms/avm'; +import { + newBaseTx as pvmBaseTx, + newExportTx as pvmExportTx, + newImportTx as pvmImportTx, + newCreateSubnetTx, + newCreateBlockchainTx, + newAddSubnetValidatorTx, + newAddPermissionlessValidatorTx, + newAddPermissionlessDelegatorTx, + newRemoveSubnetValidatorTx, + newTransferSubnetOwnershipTx, +} from '../vms/pvm'; +import { TransferableOutput } from '../serializable'; +import { nodeId } from '../fixtures/common'; +import { feeState as testFeeState } from '../fixtures/pvm'; +import { testSubnetId } from '../fixtures/transactions'; +import { blsPublicKeyBytes, blsSignatureBytes } from '../fixtures/primitives'; +import { validateAvaxBurnedAmountEtna } from './validateAvaxBurnedAmountEtna'; + +const incorrectBurnedAmount = 1n; +const correctBurnedAmount = 1000000n; + +const utxoMock = new Utxo( + utxoId(), + Id.fromString(testContext.avaxAssetID), + new TransferOutput( + new BigIntPr(1000000000000n), + new OutputOwners(new BigIntPr(0n), new Int(1), [ + Address.fromBytes(testAddress1)[0], + ]), + ), +); + +const outputMock = new TransferableOutput( + Id.fromString(testContext.avaxAssetID), + new TransferOutput( + new BigIntPr(100000000n), + new OutputOwners(new BigIntPr(0n), new Int(1), [ + Address.fromBytes(testAddress2)[0], + ]), + ), +); + +describe('validateAvaxBurnedAmountEtna', () => { + describe('unsupported tx types post-enta', () => { + const unsupportedTestData = [ + { + name: 'base tx on X', + unsignedTx: avmBaseTx( + testContext, + [testAddress1], + [utxoMock], + [outputMock], + ), + }, + { + name: 'export from X', + unsignedTx: avmExportTx( + testContext, + 'P', + [testAddress1], + [utxoMock], + [outputMock], + ), + }, + { + name: 'import from X', + unsignedTx: avmImportTx( + testContext, + 'P', + [utxoMock], + [testAddress2], + [testAddress1], + ), + }, + ]; + describe.each(unsupportedTestData)('$name', ({ unsignedTx }) => { + it('throws an error if tx type is not supported', () => { + try { + validateAvaxBurnedAmountEtna({ + unsignedTx, + context: testContext, + burnedAmount: correctBurnedAmount, + feeState: testFeeState(), + }); + } catch (error) { + expect((error as Error).message).toEqual( + 'Unsupported transaction type.', + ); + } + }); + }); + }); + + const testData = [ + { + name: 'base tx on P', + unsignedTx: pvmBaseTx( + testContext, + [testAddress1], + [utxoMock], + [outputMock], + ), + }, + { + name: 'export from P', + unsignedTx: pvmExportTx( + testContext, + 'C', + [testAddress1], + [utxoMock], + [outputMock], + ), + }, + { + name: 'import to P', + unsignedTx: pvmImportTx( + testContext, + 'C', + [utxoMock], + [testAddress2], + [testAddress1], + ), + }, + { + name: 'create subnet', + unsignedTx: newCreateSubnetTx( + testContext, + [utxoMock], + [testAddress1], + [testAddress1], + ), + }, + { + name: 'create blockchain', + unsignedTx: newCreateBlockchainTx( + testContext, + [utxoMock], + [testAddress1], + 'subnet', + 'chain', + 'vm', + ['fx1', 'fx2'], + {}, + [0], + ), + }, + { + name: 'add subnet validator', + unsignedTx: newAddSubnetValidatorTx( + testContext, + [utxoMock], + [testAddress1], + nodeId().toString(), + 0n, + 1n, + 2n, + 'subnet', + [0], + ), + }, + { + name: 'remove subnet validator', + unsignedTx: newRemoveSubnetValidatorTx( + testContext, + [utxoMock], + [testAddress1], + nodeId().toString(), + Id.fromHex(testSubnetId).toString(), + [0], + ), + }, + { + name: 'add permissionless validator (subnet)', + unsignedTx: newAddPermissionlessValidatorTx( + testContext, + [utxoMock], + [testAddress1], + nodeId().toString(), + Id.fromHex(testSubnetId).toString(), + 0n, + 120n, + 1800000n, + [], + [], + 1, + {}, + 1, + 0n, + blsPublicKeyBytes(), + blsSignatureBytes(), + ), + }, + { + name: 'add permissionless delegator (subnet)', + unsignedTx: newAddPermissionlessDelegatorTx( + testContext, + [utxoMock], + [testAddress1], + nodeId().toString(), + Id.fromHex(testSubnetId).toString(), + 0n, + 120n, + 1800000n, + [], + {}, + 1, + 0n, + ), + }, + { + name: 'transfer subnet ownership', + unsignedTx: newTransferSubnetOwnershipTx( + testContext, + [utxoMock], + [testAddress1], + Id.fromHex(testSubnetId).toString(), + [0, 2], + [testAddress2], + ), + }, + ]; + + describe.each(testData)('$name', ({ unsignedTx }) => { + it('returns true if burned amount is correct', () => { + const result = validateAvaxBurnedAmountEtna({ + unsignedTx, + context: testContext, + burnedAmount: correctBurnedAmount, + feeState: testFeeState(), + }); + + expect(result).toStrictEqual({ + isValid: true, + txFee: correctBurnedAmount, + }); + }); + + it('returns false if burned amount is not correct', () => { + const result = validateAvaxBurnedAmountEtna({ + unsignedTx, + context: testContext, + burnedAmount: incorrectBurnedAmount, + feeState: { ...testFeeState(), price: 10_000n }, + }); + + expect(result).toStrictEqual({ + isValid: false, + txFee: incorrectBurnedAmount, + }); + }); + }); +}); diff --git a/src/utils/validateAvaxBurnedAmountEtna.ts b/src/utils/validateAvaxBurnedAmountEtna.ts new file mode 100644 index 000000000..e2dff6bac --- /dev/null +++ b/src/utils/validateAvaxBurnedAmountEtna.ts @@ -0,0 +1,58 @@ +import type { Context } from '../vms/context/model'; +import { + isAddPermissionlessDelegatorTx, + isAddPermissionlessValidatorTx, + isAddSubnetValidatorTx, + isCreateChainTx, + isCreateSubnetTx, + isPvmBaseTx, + isExportTx as isPvmExportTx, + isImportTx as isPvmImportTx, + isRemoveSubnetValidatorTx, + isTransferSubnetOwnershipTx, +} from '../serializable/pvm'; +import type { UnsignedTx } from '../vms/common'; +import type { FeeState } from '../vms/pvm'; +import { calculateFee } from '../vms/pvm/txs/fee/calculator'; + +export const validateAvaxBurnedAmountEtna = ({ + unsignedTx, + context, + burnedAmount, + feeState, +}: { + unsignedTx: UnsignedTx; + context: Context; + burnedAmount: bigint; + feeState: FeeState; +}): { isValid: boolean; txFee: bigint } => { + const tx = unsignedTx.getTx(); + + const expectedFee = calculateFee( + unsignedTx.getTx(), + context.platformFeeConfig.weights, + feeState.price < context.platformFeeConfig.minPrice + ? context.platformFeeConfig.minPrice + : feeState.price, + ); + + if ( + isPvmBaseTx(tx) || + isPvmExportTx(tx) || + isPvmImportTx(tx) || + isAddPermissionlessValidatorTx(tx) || + isAddPermissionlessDelegatorTx(tx) || + isAddSubnetValidatorTx(tx) || + isCreateChainTx(tx) || + isCreateSubnetTx(tx) || + isRemoveSubnetValidatorTx(tx) || + isTransferSubnetOwnershipTx(tx) + ) { + return { + isValid: burnedAmount >= expectedFee, + txFee: burnedAmount, + }; + } + + throw new Error(`tx type is not supported`); +}; diff --git a/src/utils/validateBurnedAmount.test.ts b/src/utils/validateAvaxBurnedAmountPreEtna.test.ts similarity index 59% rename from src/utils/validateBurnedAmount.test.ts rename to src/utils/validateAvaxBurnedAmountPreEtna.test.ts index 21b68eb75..b1ac95d28 100644 --- a/src/utils/validateBurnedAmount.test.ts +++ b/src/utils/validateAvaxBurnedAmountPreEtna.test.ts @@ -1,6 +1,5 @@ -import { testAddress1, testAddress2, testEthAddress1 } from '../fixtures/vms'; +import { testAddress1, testAddress2 } from '../fixtures/vms'; import { testContext } from '../fixtures/context'; -import { newExportTxFromBaseFee, newImportTxFromBaseFee } from '../vms/evm'; import { Utxo } from '../serializable/avax/utxo'; import { utxoId } from '../fixtures/avax'; import { Address, Id } from '../serializable/fxs/common'; @@ -28,10 +27,10 @@ import { } from '../vms/pvm'; import { TransferableOutput } from '../serializable'; import { nodeId } from '../fixtures/common'; -import { validateBurnedAmount } from './validateBurnedAmount'; import { testSubnetId } from '../fixtures/transactions'; import { PrimaryNetworkID } from '../constants/networkIDs'; import { blsPublicKeyBytes, blsSignatureBytes } from '../fixtures/primitives'; +import { validateAvaxBurnedAmountPreEtna } from './validateAvaxBurnedAmountPreEtna'; const utxoMock = new Utxo( utxoId(), @@ -54,200 +53,7 @@ const outputMock = new TransferableOutput( ), ); -describe('validateBurnedAmount', () => { - describe('missing burned amount', () => { - it('calculates the burned amount', () => { - const unsignedTx = avmBaseTx( - testContext, - [testAddress1], - [utxoMock], - [outputMock], - ); - - const result = validateBurnedAmount({ - unsignedTx, - context: testContext, - }); - - expect(result).toStrictEqual({ - isValid: true, - txFee: testContext.baseTxFee, - }); - }); - }); - - describe('export from C', () => { - const unsignedTx = newExportTxFromBaseFee( - testContext, - 25n, - 1000000000n, - 'X', - testEthAddress1, - [testAddress1], - 1n, - ); - - it('throws if fee data is missing', () => { - expect(() => - validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower - }), - ).toThrowError('missing evm fee data'); - - expect(() => - validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower - evmBaseFee: 25n, - }), - ).toThrowError('missing evm fee data'); - }); - - it('throws if evmFeeTolerance is incorrect', () => { - expect(() => - validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower, - evmBaseFee: 25n, - evmFeeTolerance: 0.5, - }), - ).toThrowError('evmFeeTolerance must be [1,100]'); - - expect(() => - validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower, - evmBaseFee: 25n, - evmFeeTolerance: 101, - }), - ).toThrowError('evmFeeTolerance must be [1,100]'); - }); - - it('returns true if burned amount is in the tolerance range', () => { - const resultLower = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - const resultHigher = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 125n) / 100n, // 25% higher - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - expect(resultLower).toStrictEqual({ - isValid: true, - txFee: (280750n * 75n) / 100n, - }); - expect(resultHigher).toStrictEqual({ - isValid: true, - txFee: (280750n * 125n) / 100n, - }); - }); - - it('returns false if burned amount is not in the tolerance range', () => { - const resultLower = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 49n) / 100n, // 51% lower - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - const resultHigher = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 151n) / 100n, // 51% higher - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - expect(resultLower).toStrictEqual({ - isValid: false, - txFee: (280750n * 49n) / 100n, - }); - expect(resultHigher).toStrictEqual({ - isValid: false, - txFee: (280750n * 151n) / 100n, - }); - }); - }); - - describe('import to C', () => { - const unsignedTx = newImportTxFromBaseFee( - testContext, - testEthAddress1, - [testAddress1], - [utxoMock], - 'X', - 25n, - ); - - it('returns true if burned amount is in the tolerance range', () => { - const resultLower = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 75n) / 100n, // 25% lower - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - const resultHigher = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 125n) / 100n, // 25% higher - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - expect(resultLower).toStrictEqual({ - isValid: true, - txFee: (280750n * 75n) / 100n, - }); - expect(resultHigher).toStrictEqual({ - isValid: true, - txFee: (280750n * 125n) / 100n, - }); - }); - - it('returns false if burned amount is not in the tolerance range', () => { - const resultLower = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 49n) / 100n, // 51% lower - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - const resultHigher = validateBurnedAmount({ - unsignedTx, - context: testContext, - burnedAmount: (280750n * 151n) / 100n, // 51% higher - evmBaseFee: 25n, - evmFeeTolerance: 50.9, - }); - - expect(resultLower).toStrictEqual({ - isValid: false, - txFee: (280750n * 49n) / 100n, - }); - expect(resultHigher).toStrictEqual({ - isValid: false, - txFee: (280750n * 151n) / 100n, - }); - }); - }); - +describe('validateAvaxBurnedAmountPreEtna', () => { const testData = [ { name: 'base tx on X', @@ -514,7 +320,7 @@ describe('validateBurnedAmount', () => { describe.each(testData)('$name', ({ unsignedTx, correctBurnedAmount }) => { it('returns true if burned amount is correct', () => { - const result = validateBurnedAmount({ + const result = validateAvaxBurnedAmountPreEtna({ unsignedTx, context: testContext, burnedAmount: correctBurnedAmount, @@ -527,7 +333,7 @@ describe('validateBurnedAmount', () => { }); it('returns false if burned amount is not correct', () => { - const result = validateBurnedAmount({ + const result = validateAvaxBurnedAmountPreEtna({ unsignedTx, context: testContext, burnedAmount: correctBurnedAmount - 1n, diff --git a/src/utils/validateAvaxBurnedAmountPreEtna.ts b/src/utils/validateAvaxBurnedAmountPreEtna.ts new file mode 100644 index 000000000..0d77a9cb6 --- /dev/null +++ b/src/utils/validateAvaxBurnedAmountPreEtna.ts @@ -0,0 +1,102 @@ +import type { Context } from '../vms/context/model'; +import { + isAddDelegatorTx, + isAddPermissionlessDelegatorTx, + isAddPermissionlessValidatorTx, + isAddSubnetValidatorTx, + isAddValidatorTx, + isCreateChainTx, + isCreateSubnetTx, + isPvmBaseTx, + isExportTx as isPvmExportTx, + isImportTx as isPvmImportTx, + isRemoveSubnetValidatorTx, + isTransferSubnetOwnershipTx, + isTransformSubnetTx, +} from '../serializable/pvm'; +import type { UnsignedTx } from '../vms/common'; +import { + isAvmBaseTx, + isExportTx as isAvmExportTx, + isImportTx as isAvmImportTx, +} from '../serializable/avm'; +import { PrimaryNetworkID } from '../constants/networkIDs'; + +export const validateAvaxBurnedAmountPreEtna = ({ + unsignedTx, + context, + burnedAmount, +}: { + unsignedTx: UnsignedTx; + context: Context; + burnedAmount: bigint; +}): { isValid: boolean; txFee: bigint } => { + const tx = unsignedTx.getTx(); + + if (isAddValidatorTx(tx)) { + return validate(burnedAmount, context.addPrimaryNetworkValidatorFee); + } + + if (isAddDelegatorTx(tx)) { + return validate(burnedAmount, context.addPrimaryNetworkDelegatorFee); + } + + if (isCreateSubnetTx(tx)) { + return validate(burnedAmount, context.createSubnetTxFee); + } + + if (isCreateChainTx(tx)) { + return validate(burnedAmount, context.createBlockchainTxFee); + } + + if (isAddSubnetValidatorTx(tx)) { + return validate(burnedAmount, context.addSubnetValidatorFee); + } + + if (isTransformSubnetTx(tx)) { + return validate(burnedAmount, context.transformSubnetTxFee); + } + + if (isAddPermissionlessValidatorTx(tx)) { + const isPrimarySubnet = + tx.subnetValidator.subnetId.toString() === PrimaryNetworkID.toString(); + + return validate( + burnedAmount, + isPrimarySubnet + ? context.addPrimaryNetworkValidatorFee + : context.addSubnetValidatorFee, + ); + } + + if (isAddPermissionlessDelegatorTx(tx)) { + const isPrimarySubnet = + tx.subnetValidator.subnetId.toString() === PrimaryNetworkID.toString(); + return validate( + burnedAmount, + isPrimarySubnet + ? context.addPrimaryNetworkDelegatorFee + : context.addSubnetDelegatorFee, + ); + } + + if ( + isAvmBaseTx(tx) || + isPvmBaseTx(tx) || + isAvmExportTx(tx) || + isAvmImportTx(tx) || + isPvmExportTx(tx) || + isPvmImportTx(tx) || + isRemoveSubnetValidatorTx(tx) || + isTransferSubnetOwnershipTx(tx) + ) { + return validate(burnedAmount, context.baseTxFee); + } + + throw new Error(`tx type is not supported`); +}; + +const validate = (burnedAmount: bigint, expectedAmount: bigint) => ({ + isValid: burnedAmount === expectedAmount, + txFee: expectedAmount, +}); diff --git a/src/utils/validateBurnedAmount.ts b/src/utils/validateBurnedAmount.ts index f162e6e55..563412b5e 100644 --- a/src/utils/validateBurnedAmount.ts +++ b/src/utils/validateBurnedAmount.ts @@ -1,46 +1,57 @@ import type { Context } from '../vms/context/model'; -import { - isAddDelegatorTx, - isAddPermissionlessDelegatorTx, - isAddPermissionlessValidatorTx, - isAddSubnetValidatorTx, - isAddValidatorTx, - isCreateChainTx, - isCreateSubnetTx, - isPvmBaseTx, - isExportTx as isPvmExportTx, - isImportTx as isPvmImportTx, - isRemoveSubnetValidatorTx, - isTransferSubnetOwnershipTx, - isTransformSubnetTx, -} from '../serializable/pvm'; import type { Transaction, UnsignedTx } from '../vms/common'; import type { EVMTx } from '../serializable/evm'; import { isImportExportTx as isEvmImportExportTx } from '../serializable/evm'; -import { costCorethTx } from './costs'; +import { getBurnedAmountByTx } from './getBurnedAmountByTx'; +import type { AvaxTx } from '../serializable/avax'; +import type { FeeState } from '../vms/pvm'; +import { validateEvmBurnedAmount } from './validateEvmBurnedAmount'; +import type { GetUpgradesInfoResponse } from '../info/model'; +import { isEtnaEnabled } from './isEtnaEnabled'; +import { validateAvaxBurnedAmountEtna } from './validateAvaxBurnedAmountEtna'; +import { validateAvaxBurnedAmountPreEtna } from './validateAvaxBurnedAmountPreEtna'; import { isAvmBaseTx, isExportTx as isAvmExportTx, isImportTx as isAvmImportTx, } from '../serializable/avm'; -import { getBurnedAmountByTx } from './getBurnedAmountByTx'; -import type { AvaxTx } from '../serializable/avax'; -import { PrimaryNetworkID } from '../constants/networkIDs'; +import { + isAddDelegatorTx, + isAddValidatorTx, + isTransformSubnetTx, +} from '../serializable/pvm'; const _getBurnedAmount = (tx: Transaction, context: Context) => { const burnedAmounts = getBurnedAmountByTx(tx as AvaxTx | EVMTx); return burnedAmounts.get(context.avaxAssetID) ?? 0n; }; +// Transactions that are deprecated or not implemented for Etna +// Todo: remove isAvmBaseTx, isAvmExportTx and isAvmImportTx when avm dynmamic fee is implemented +const isPreEtnaTx = (tx: Transaction) => { + return ( + isAvmBaseTx(tx) || // not implemented + isAvmExportTx(tx) || // not implemented + isAvmImportTx(tx) || // not implemented + isAddValidatorTx(tx) || // deprecated + isAddDelegatorTx(tx) || // deprecated + isTransformSubnetTx(tx) // deprecated + ); +}; + export const validateBurnedAmount = ({ unsignedTx, context, + feeState, + upgradesInfo, burnedAmount, evmBaseFee, evmFeeTolerance, }: { unsignedTx: UnsignedTx; context: Context; + feeState: FeeState; + upgradesInfo: GetUpgradesInfoResponse; burnedAmount?: bigint; evmBaseFee?: bigint; // fetched from the network and converted into nAvax (https://docs.avax.network/quickstart/transaction-fees#c-chain-fees) evmFeeTolerance?: number; // tolerance percentage range where the burned amount is considered valid. e.g.: with evmFeeTolerance = 20% -> (evmBaseFee * 0.8 <= burnedAmount <= evmBaseFee * 1.2) @@ -48,89 +59,25 @@ export const validateBurnedAmount = ({ const tx = unsignedTx.getTx(); const burned = burnedAmount ?? _getBurnedAmount(tx, context); - const validate = (expectedAmount: bigint) => ({ - isValid: burned === expectedAmount, - txFee: expectedAmount, - }); - if (isEvmImportExportTx(tx)) { - if (!evmBaseFee || !evmFeeTolerance) { - throw new Error('missing evm fee data'); - } - - const feeToleranceInt = Math.floor(evmFeeTolerance); - - if (feeToleranceInt < 1 || feeToleranceInt > 100) { - throw new Error('evmFeeTolerance must be [1,100]'); - } - - const feeAmount = evmBaseFee * costCorethTx(unsignedTx); - const min = (feeAmount * (100n - BigInt(feeToleranceInt))) / 100n; - const max = (feeAmount * (100n + BigInt(feeToleranceInt))) / 100n; - - return { - isValid: burned >= min && burned <= max, - txFee: burned, - }; - } - - if (isAddValidatorTx(tx)) { - return validate(context.addPrimaryNetworkValidatorFee); + return validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: burned, + evmBaseFee, + evmFeeTolerance, + }); } - - if (isAddDelegatorTx(tx)) { - return validate(context.addPrimaryNetworkDelegatorFee); - } - - if (isCreateSubnetTx(tx)) { - return validate(context.createSubnetTxFee); + if (isEtnaEnabled(upgradesInfo) || !isPreEtnaTx(tx)) { + return validateAvaxBurnedAmountEtna({ + unsignedTx, + context, + burnedAmount: burned, + feeState, + }); } - - if (isCreateChainTx(tx)) { - return validate(context.createBlockchainTxFee); - } - - if (isAddSubnetValidatorTx(tx)) { - return validate(context.addSubnetValidatorFee); - } - - if (isTransformSubnetTx(tx)) { - return validate(context.transformSubnetTxFee); - } - - if (isAddPermissionlessValidatorTx(tx)) { - const isPrimarySubnet = - tx.subnetValidator.subnetId.toString() === PrimaryNetworkID.toString(); - - return validate( - isPrimarySubnet - ? context.addPrimaryNetworkValidatorFee - : context.addSubnetValidatorFee, - ); - } - - if (isAddPermissionlessDelegatorTx(tx)) { - const isPrimarySubnet = - tx.subnetValidator.subnetId.toString() === PrimaryNetworkID.toString(); - return validate( - isPrimarySubnet - ? context.addPrimaryNetworkDelegatorFee - : context.addSubnetDelegatorFee, - ); - } - - if ( - isAvmBaseTx(tx) || - isPvmBaseTx(tx) || - isAvmExportTx(tx) || - isAvmImportTx(tx) || - isPvmExportTx(tx) || - isPvmImportTx(tx) || - isRemoveSubnetValidatorTx(tx) || - isTransferSubnetOwnershipTx(tx) - ) { - return validate(context.baseTxFee); - } - - throw new Error(`tx type is not supported`); + return validateAvaxBurnedAmountPreEtna({ + unsignedTx, + context, + burnedAmount: burned, + }); }; diff --git a/src/utils/validateEvmBurnedAmount.test.ts b/src/utils/validateEvmBurnedAmount.test.ts new file mode 100644 index 000000000..644ce6b54 --- /dev/null +++ b/src/utils/validateEvmBurnedAmount.test.ts @@ -0,0 +1,164 @@ +import { testAddress1, testEthAddress1 } from '../fixtures/vms'; +import { testContext } from '../fixtures/context'; +import { newExportTxFromBaseFee, newImportTxFromBaseFee } from '../vms/evm'; +import { Utxo } from '../serializable/avax/utxo'; +import { utxoId } from '../fixtures/avax'; +import { Address, Id } from '../serializable/fxs/common'; +import { OutputOwners, TransferOutput } from '../serializable/fxs/secp256k1'; +import { BigIntPr, Int } from '../serializable/primitives'; +import { validateEvmBurnedAmount } from './validateEvmBurnedAmount'; + +const utxoMock = new Utxo( + utxoId(), + Id.fromString(testContext.avaxAssetID), + new TransferOutput( + new BigIntPr(1000000000000n), + new OutputOwners(new BigIntPr(0n), new Int(1), [ + Address.fromBytes(testAddress1)[0], + ]), + ), +); + +describe('validateEvmBurnedAmount', () => { + describe('export from C', () => { + const unsignedTx = newExportTxFromBaseFee( + testContext, + 25n, + 1000000000n, + 'X', + testEthAddress1, + [testAddress1], + 1n, + ); + it('throws if evmFeeTolerance is incorrect', () => { + expect(() => + validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 75n) / 100n, // 25% lower, + evmBaseFee: 25n, + evmFeeTolerance: 0.5, + }), + ).toThrowError('evmFeeTolerance must be [1,100]'); + + expect(() => + validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 75n) / 100n, // 25% lower, + evmBaseFee: 25n, + evmFeeTolerance: 101, + }), + ).toThrowError('evmFeeTolerance must be [1,100]'); + }); + + it('returns true if burned amount is in the tolerance range', () => { + const resultLower = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 75n) / 100n, // 25% lower + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + const resultHigher = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 125n) / 100n, // 25% higher + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + expect(resultLower).toStrictEqual({ + isValid: true, + txFee: (280750n * 75n) / 100n, + }); + expect(resultHigher).toStrictEqual({ + isValid: true, + txFee: (280750n * 125n) / 100n, + }); + }); + + it('returns false if burned amount is not in the tolerance range', () => { + const resultLower = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 49n) / 100n, // 51% lower + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + const resultHigher = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 151n) / 100n, // 51% higher + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + expect(resultLower).toStrictEqual({ + isValid: false, + txFee: (280750n * 49n) / 100n, + }); + expect(resultHigher).toStrictEqual({ + isValid: false, + txFee: (280750n * 151n) / 100n, + }); + }); + }); + + describe('import to C', () => { + const unsignedTx = newImportTxFromBaseFee( + testContext, + testEthAddress1, + [testAddress1], + [utxoMock], + 'X', + 25n, + ); + + it('returns true if burned amount is in the tolerance range', () => { + const resultLower = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 75n) / 100n, // 25% lower + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + const resultHigher = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 125n) / 100n, // 25% higher + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + expect(resultLower).toStrictEqual({ + isValid: true, + txFee: (280750n * 75n) / 100n, + }); + expect(resultHigher).toStrictEqual({ + isValid: true, + txFee: (280750n * 125n) / 100n, + }); + }); + + it('returns false if burned amount is not in the tolerance range', () => { + const resultLower = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 49n) / 100n, // 51% lower + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + const resultHigher = validateEvmBurnedAmount({ + unsignedTx, + burnedAmount: (280750n * 151n) / 100n, // 51% higher + evmBaseFee: 25n, + evmFeeTolerance: 50.9, + }); + + expect(resultLower).toStrictEqual({ + isValid: false, + txFee: (280750n * 49n) / 100n, + }); + expect(resultHigher).toStrictEqual({ + isValid: false, + txFee: (280750n * 151n) / 100n, + }); + }); + }); +}); diff --git a/src/utils/validateEvmBurnedAmount.ts b/src/utils/validateEvmBurnedAmount.ts new file mode 100644 index 000000000..b206708c5 --- /dev/null +++ b/src/utils/validateEvmBurnedAmount.ts @@ -0,0 +1,39 @@ +import type { UnsignedTx } from '../vms/common'; +import { isImportExportTx as isEvmImportExportTx } from '../serializable/evm'; +import { costCorethTx } from './costs'; + +export const validateEvmBurnedAmount = ({ + unsignedTx, + burnedAmount, + evmBaseFee, + evmFeeTolerance, +}: { + unsignedTx: UnsignedTx; + burnedAmount: bigint; + evmBaseFee?: bigint; // fetched from the network and converted into nAvax (https://docs.avax.network/quickstart/transaction-fees#c-chain-fees) + evmFeeTolerance?: number; // tolerance percentage range where the burned amount is considered valid. e.g.: with evmFeeTolerance = 20% -> (evmBaseFee * 0.8 <= burnedAmount <= evmBaseFee * 1.2) +}): { isValid: boolean; txFee: bigint } => { + const tx = unsignedTx.getTx(); + + if (!isEvmImportExportTx(tx)) { + throw new Error(`tx type is not supported`); + } + if (!evmBaseFee || !evmFeeTolerance) { + throw new Error('missing evm fee data'); + } + + const feeToleranceInt = Math.floor(evmFeeTolerance); + + if (feeToleranceInt < 1 || feeToleranceInt > 100) { + throw new Error('evmFeeTolerance must be [1,100]'); + } + + const feeAmount = evmBaseFee * costCorethTx(unsignedTx); + const min = (feeAmount * (100n - BigInt(feeToleranceInt))) / 100n; + const max = (feeAmount * (100n + BigInt(feeToleranceInt))) / 100n; + + return { + isValid: burnedAmount >= min && burnedAmount <= max, + txFee: burnedAmount, + }; +};