From d587389fef0a709db94e32cb1e39ff1fef719ba0 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 14 Aug 2024 15:44:05 -0600 Subject: [PATCH 01/39] chore: vscode auto import path setting relative --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3662b3700..5f6fc7807 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { + "typescript.preferences.importModuleSpecifier": "relative", "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} From bba2054d67192857d4320af7ee7f12daad244788 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 14 Aug 2024 15:45:56 -0600 Subject: [PATCH 02/39] feat: base pvm complexity fns --- src/crypto/secp256k1.ts | 2 + src/serializable/avax/transferableOutput.ts | 5 + src/vms/common/fees/dimensions.ts | 50 ++++ src/vms/pvm/txs/fee/complexity.test.ts | 194 +++++++++++++++ src/vms/pvm/txs/fee/complexity.ts | 260 ++++++++++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 src/vms/common/fees/dimensions.ts create mode 100644 src/vms/pvm/txs/fee/complexity.test.ts create mode 100644 src/vms/pvm/txs/fee/complexity.ts diff --git a/src/crypto/secp256k1.ts b/src/crypto/secp256k1.ts index 0bd1d6131..77a13f52d 100644 --- a/src/crypto/secp256k1.ts +++ b/src/crypto/secp256k1.ts @@ -4,6 +4,8 @@ import * as secp from '@noble/secp256k1'; import { Address } from 'micro-eth-signer'; import { concatBytes, hexToBuffer } from '../utils/buffer'; +export const SIGNATURE_LENGTH = 65; + export function randomPrivateKey() { return secp.utils.randomPrivateKey(); } diff --git a/src/serializable/avax/transferableOutput.ts b/src/serializable/avax/transferableOutput.ts index 191efd959..1b4296675 100644 --- a/src/serializable/avax/transferableOutput.ts +++ b/src/serializable/avax/transferableOutput.ts @@ -49,6 +49,11 @@ export class TransferableOutput { return this.assetId.toString(); } + // TODO: Should we add this here? + getOutput() { + return this.output; + } + amount() { return this.output.amount(); } diff --git a/src/vms/common/fees/dimensions.ts b/src/vms/common/fees/dimensions.ts new file mode 100644 index 000000000..a2591c775 --- /dev/null +++ b/src/vms/common/fees/dimensions.ts @@ -0,0 +1,50 @@ +export enum FeeDimensions { + Bandwidth = 0, + DBRead = 1, + DBWrite = 2, + Compute = 3, +} + +type DimensionValue = number; + +export type Dimensions = Record; + +export const getEmptyDimensions = (): Dimensions => ({ + [FeeDimensions.Bandwidth]: 0, + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}); + +export const makeDimension = ( + bandwidth: DimensionValue, + dbRead: DimensionValue, + dbWrite: DimensionValue, + compute: DimensionValue, +): Dimensions => ({ + [FeeDimensions.Bandwidth]: bandwidth, + [FeeDimensions.DBRead]: dbRead, + [FeeDimensions.DBWrite]: dbWrite, + [FeeDimensions.Compute]: compute, +}); + +/** + * Adds a number of dimensions together. + * + * @returns The sum of the dimensions. + */ +export const addDimensions = (...dimensions: Dimensions[]): Dimensions => { + const result: Dimensions = { + [FeeDimensions.Bandwidth]: 0, + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, + }; + for (const dimension of dimensions) { + result[FeeDimensions.Bandwidth] += dimension[FeeDimensions.Bandwidth]; + result[FeeDimensions.DBRead] += dimension[FeeDimensions.DBRead]; + result[FeeDimensions.DBWrite] += dimension[FeeDimensions.DBWrite]; + result[FeeDimensions.Compute] += dimension[FeeDimensions.Compute]; + } + return result; +}; diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts new file mode 100644 index 000000000..a9263727a --- /dev/null +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -0,0 +1,194 @@ +import { utxoId } from '../../../../fixtures/avax'; +import { address, id } from '../../../../fixtures/common'; +import { bigIntPr, int } from '../../../../fixtures/primitives'; +import { signer } from '../../../../fixtures/pvm'; +import { + Input, + OutputOwners, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../../serializable'; +import { + SignerEmpty, + StakeableLockIn, + StakeableLockOut, +} from '../../../../serializable/pvm'; +import { makeDimension } from '../../../common/fees/dimensions'; +import { + inputComplexity, + outputComplexity, + ownerComplexity, + signerComplexity, +} from './complexity'; + +const makeOutputOwners = (numOfAddresses = 0) => + new OutputOwners( + bigIntPr(), + int(), + new Array(numOfAddresses).fill(address()), + ); + +const makeTransferableOutput = (numOfAddresses = 0) => + new TransferableOutput( + id(), + new TransferOutput(bigIntPr(), makeOutputOwners(numOfAddresses)), + ); + +const makeTransferableInput = (numOfSigInts = 0) => + new TransferableInput( + utxoId(), + id(), + new TransferInput( + bigIntPr(), + new Input(new Array(numOfSigInts).fill(int())), + ), + ); + +describe('Complexity', () => { + describe('outputComplexity', () => { + test('empty transferable output', () => { + const result = outputComplexity([]); + + expect(result).toEqual(makeDimension(0, 0, 0, 0)); + }); + + test('any can spend', () => { + const result = outputComplexity([makeTransferableOutput()]); + + expect(result).toEqual(makeDimension(60, 0, 1, 0)); + }); + + test('one owner', () => { + const result = outputComplexity([makeTransferableOutput(1)]); + + expect(result).toEqual(makeDimension(80, 0, 1, 0)); + }); + + test('three owners', () => { + const result = outputComplexity([makeTransferableOutput(3)]); + + expect(result).toEqual(makeDimension(120, 0, 1, 0)); + }); + + test('locked stakeable', () => { + const result = outputComplexity([ + new TransferableOutput( + id(), + new StakeableLockOut( + bigIntPr(), + new TransferOutput(bigIntPr(), makeOutputOwners(3)), + ), + ), + ]); + + expect(result).toEqual(makeDimension(132, 0, 1, 0)); + }); + }); + + describe('inputComplexity', () => { + test('any can spend', () => { + const result = inputComplexity([makeTransferableInput()]); + + expect(result).toEqual( + makeDimension( + 92, + 1, + 1, + 0, // TODO: Implement + ), + ); + }); + + test('one owner', () => { + const result = inputComplexity([makeTransferableInput(1)]); + + expect(result).toEqual( + makeDimension( + 161, + 1, + 1, + 0, // TODO: Implement + ), + ); + }); + + test('three owners', () => { + const result = inputComplexity([makeTransferableInput(3)]); + + expect(result).toEqual( + makeDimension( + 299, + 1, + 1, + 0, // TODO: Implement + ), + ); + }); + + test('locked stakeable', () => { + const result = inputComplexity([ + new TransferableInput( + utxoId(), + id(), + new StakeableLockIn( + bigIntPr(), + new TransferInput(bigIntPr(), new Input(new Array(3).fill(int()))), + ), + ), + ]); + + expect(result).toEqual( + makeDimension( + 311, + 1, + 1, + 0, // TODO: Implement + ), + ); + }); + }); + + describe('ownerComplexity', () => { + test('any can spend', () => { + const result = ownerComplexity(makeOutputOwners()); + + expect(result).toEqual(makeDimension(16, 0, 0, 0)); + }); + + test('one owner', () => { + const result = ownerComplexity(makeOutputOwners(1)); + + expect(result).toEqual(makeDimension(36, 0, 0, 0)); + }); + + test('three owners', () => { + const result = ownerComplexity(makeOutputOwners(3)); + + expect(result).toEqual(makeDimension(76, 0, 0, 0)); + }); + }); + + describe('signerComplexity', () => { + test('empty signer', () => { + const result = signerComplexity(new SignerEmpty()); + + expect(result).toEqual(makeDimension(0, 0, 0, 0)); + }); + + test('bls pop', () => { + const result = signerComplexity(signer()); + + expect(result).toEqual( + makeDimension( + 144, + 0, + 0, + // TODO: Implement complexity + 0, + ), + ); + }); + }); +}); diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts new file mode 100644 index 000000000..d6447444a --- /dev/null +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -0,0 +1,260 @@ +import { + PUBLIC_KEY_LENGTH, + SIGNATURE_LENGTH as BLS_SIGNATURE_LENGTH, +} from '../../../../crypto/bls'; +import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; +import type { OutputOwners } from '../../../../serializable'; +import { NodeId } from '../../../../serializable'; +import type { + BaseTx, + TransferableInput, + TransferableOutput, +} from '../../../../serializable/avax'; +import type { + AddPermissionlessValidatorTx, + Signer, +} from '../../../../serializable/pvm'; +import { SignerEmpty } from '../../../../serializable/pvm'; +import { + isStakeableLockIn, + isStakeableLockOut, + isTransferOut, +} from '../../../../utils'; +import type { Dimensions } from '../../../common/fees/dimensions'; +import { + FeeDimensions, + addDimensions, + getEmptyDimensions, + makeDimension, +} from '../../../common/fees/dimensions'; + +/** + * Number of bytes per long. + */ +const LONG_LEN = 8; + +const ID_LEN = 32; + +const SHORT_ID_LEN = 20; + +/** + * Number of bytes per int. + */ +const INT_LEN = 4; + +const INTRINSIC_VALIDATOR_BANDWIDTH = + NodeId.length + // Node ID (Short ID = 20) + LONG_LEN + // Start + LONG_LEN + // End + LONG_LEN; // Weight + +const INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH = + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + +ID_LEN; // Subnet ID (ID Length = 32) + +const INTRINSIC_OUTPUT_BANDWIDTH = + ID_LEN + // assetID + INT_LEN; // output typeID + +const INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN; // output typeID + +const INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN + // threshold + INT_LEN; // number of addresses + +const INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH = + LONG_LEN + // amount + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; + +const INTRINSIC_INPUT_BANDWIDTH = + ID_LEN + // txID + INT_LEN + // output index + ID_LEN + // assetID + INT_LEN + // input typeID + INT_LEN; // credential typeID + +const INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN; // input typeID + +const INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH = + INT_LEN + // num indices + INT_LEN; // num signatures + +const INTRINSIC_SECP256K1_FX_TRANSFERABLE_INPUT_BANDWIDTH = + LONG_LEN + // amount + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; + +const INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH = + INT_LEN + // Signature index + SIGNATURE_LENGTH; // Signature + +const INTRINSIC_POP_BANDWIDTH = + PUBLIC_KEY_LENGTH + // Public key + BLS_SIGNATURE_LENGTH; // Signature + +const INTRINSIC_INPUT_DB_READ = 1; +const INTRINSIC_INPUT_DB_WRITE = 1; +const INTRINSIC_OUTPUT_DB_WRITE = 1; + +const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + 2 + // codec version + INT_LEN + // typeID + INT_LEN + // networkID + ID_LEN + // blockchainID + INT_LEN + // number of outputs + INT_LEN + // number of inputs + INT_LEN + // length of memo + INT_LEN, // number of credentials + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Signer typeID + INT_LEN + // Num stake outs + INT_LEN + // Validator rewards typeID + INT_LEN + // Delegator rewards typeID + INT_LEN, // Delegation shares + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +/** + * Returns the complexity outputs add to a transaction. + */ +export const outputComplexity = (output: TransferableOutput[]): Dimensions => { + let complexity = getEmptyDimensions(); + + for (const out of output) { + // outputComplexity logic + const outComplexity: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_OUTPUT_BANDWIDTH + INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH, + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: INTRINSIC_OUTPUT_DB_WRITE, + [FeeDimensions.Compute]: 0, + }; + + let numberOfAddresses = 0; + + // TODO: Double check this if logic. + if (isStakeableLockOut(out.output)) { + outComplexity[FeeDimensions.Bandwidth] += + INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH; + numberOfAddresses = out.output.getOutputOwners().addrs.length; + } else if (isTransferOut(out.output)) { + numberOfAddresses = out.output.outputOwners.addrs.length; + } + + const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; + + outComplexity[FeeDimensions.Bandwidth] += addressBandwidth; + + // Finish with OutputComplexity logic + complexity = addDimensions(complexity, outComplexity); + } + + return complexity; +}; + +/** + * Returns the complexity inputs add to a transaction. + * + * It includes the complexity that the corresponding credentials will add. + */ +export const inputComplexity = (inputs: TransferableInput[]): Dimensions => { + let complexity = getEmptyDimensions(); + + for (const input of inputs) { + const inputComplexity: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_INPUT_BANDWIDTH + + INTRINSIC_SECP256K1_FX_TRANSFERABLE_INPUT_BANDWIDTH, + [FeeDimensions.DBRead]: INTRINSIC_INPUT_DB_READ, + [FeeDimensions.DBWrite]: INTRINSIC_INPUT_DB_WRITE, + [FeeDimensions.Compute]: 0, // TODO: Add compute complexity. + }; + + // TODO: Double check this if logic. + if (isStakeableLockIn(input.input)) { + inputComplexity[FeeDimensions.Bandwidth] += + INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH; + } + + const numberOfSignatures = input.sigIndicies().length; + + const signatureBandwidth = + numberOfSignatures * INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH; + + inputComplexity[FeeDimensions.Bandwidth] += signatureBandwidth; + + // Finalize + complexity = addDimensions(complexity, inputComplexity); + } + + return complexity; +}; + +export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { + if (signer instanceof SignerEmpty) { + return getEmptyDimensions(); + } + + return makeDimension( + INTRINSIC_POP_BANDWIDTH, + 0, + 0, + 0, // TODO: Add compute complexity. + ); +}; + +export const ownerComplexity = (owner: OutputOwners): Dimensions => { + const complexity = getEmptyDimensions(); + + const numberOfAddresses = owner.addrs.length; + const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; + + const bandwidth = + addressBandwidth + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; + + complexity[FeeDimensions.Bandwidth] = bandwidth; + + return complexity; +}; + +// See: vms/platformvm/txs/fee/complexity.go:583 +const baseTxComplexity = (tx: BaseTx): Dimensions => { + const outputsComplexity = outputComplexity(tx.outputs); + const inputsComplexity = inputComplexity(tx.inputs); + + const complexity = addDimensions(outputsComplexity, inputsComplexity); + + // TODO: Verify if .toBytes().length is correct. + // See: vms/platformvm/txs/fee/complexity.go:598 + complexity[FeeDimensions.Bandwidth] += tx.memo.toBytes().length; + + return getEmptyDimensions(); +}; + +export const addPermissionlessValidatorTx = ( + tx: AddPermissionlessValidatorTx, +): Dimensions => { + return addDimensions( + baseTxComplexity(tx.baseTx), + signerComplexity(tx.signer), + outputComplexity(tx.stake), + ownerComplexity(tx.getValidatorRewardsOwner()), + ownerComplexity(tx.getDelegatorRewardsOwner()), + ); +}; From b64d15a95ac5413e6d6bd975e709bdd7681d60a9 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 15 Aug 2024 09:53:06 -0600 Subject: [PATCH 03/39] feat: add p-chain auth complexity --- src/vms/pvm/txs/fee/complexity.test.ts | 54 ++++++++++++++++++++++++- src/vms/pvm/txs/fee/complexity.ts | 56 ++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index a9263727a..7c2de7f61 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -1,6 +1,6 @@ import { utxoId } from '../../../../fixtures/avax'; import { address, id } from '../../../../fixtures/common'; -import { bigIntPr, int } from '../../../../fixtures/primitives'; +import { bigIntPr, int, ints } from '../../../../fixtures/primitives'; import { signer } from '../../../../fixtures/pvm'; import { Input, @@ -17,6 +17,7 @@ import { } from '../../../../serializable/pvm'; import { makeDimension } from '../../../common/fees/dimensions'; import { + authComplexity, inputComplexity, outputComplexity, ownerComplexity, @@ -185,10 +186,59 @@ describe('Complexity', () => { 144, 0, 0, - // TODO: Implement complexity + // TODO: Implement compute 0, ), ); }); }); + + describe('authComplexity', () => { + test('any can spend', () => { + const result = authComplexity(new Input([])); + + expect(result).toEqual( + makeDimension( + 8, + 0, + 0, + 0, // TODO: Implement + ), + ); + }); + + test('one owner', () => { + const result = authComplexity(new Input([int()])); + + expect(result).toEqual( + makeDimension( + 77, + 0, + 0, + 0, // TODO: Implement + ), + ); + }); + + test('three owners', () => { + const result = authComplexity(new Input(ints())); + + expect(result).toEqual( + makeDimension( + 215, + 0, + 0, + 0, // TODO: Implement + ), + ); + }); + + test('invalid auth type', () => { + expect(() => { + authComplexity(int()); + }).toThrow( + 'Unable to calculate auth complexity of transaction. Expected Input as subnet auth.', + ); + }); + }); }); diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index d6447444a..f0655c7b1 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -5,6 +5,7 @@ import { import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; import type { OutputOwners } from '../../../../serializable'; import { NodeId } from '../../../../serializable'; +import { Input } from '../../../../serializable/fxs/secp256k1'; import type { BaseTx, TransferableInput, @@ -12,6 +13,7 @@ import type { } from '../../../../serializable/avax'; import type { AddPermissionlessValidatorTx, + AddSubnetValidatorTx, Signer, } from '../../../../serializable/pvm'; import { SignerEmpty } from '../../../../serializable/pvm'; @@ -27,6 +29,7 @@ import { getEmptyDimensions, makeDimension, } from '../../../common/fees/dimensions'; +import type { Serializable } from '../../../common/types'; /** * Number of bytes per long. @@ -130,6 +133,17 @@ const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; +const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH + // Subnet Validator + INT_LEN + // Subnet auth typeID + INT_LEN, // Subnet auth credential typeID + [FeeDimensions.DBRead]: 2, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + /** * Returns the complexity outputs add to a transaction. */ @@ -220,17 +234,42 @@ export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { }; export const ownerComplexity = (owner: OutputOwners): Dimensions => { - const complexity = getEmptyDimensions(); - const numberOfAddresses = owner.addrs.length; const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; const bandwidth = addressBandwidth + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; - complexity[FeeDimensions.Bandwidth] = bandwidth; + return makeDimension(bandwidth, 0, 0, 0); +}; - return complexity; +/** + * Returns the complexity an authorization adds to a transaction. + * It does not include the typeID of the authorization. + * It does include the complexity that the corresponding credential will add. + * It does not include the typeID of the credential. + */ +export const authComplexity = (input: Serializable): Dimensions => { + // TODO: Not a fan of this. May be better to re-type `subnetAuth` as `Input` in `AddSubnetValidatorTx`? + if (!(input instanceof Input)) { + throw new Error( + 'Unable to calculate auth complexity of transaction. Expected Input as subnet auth.', + ); + } + + const numberOfSignatures = input.values().length; + + const signatureBandwidth = + numberOfSignatures * INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH; + + const bandwidth = signatureBandwidth + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; + + return makeDimension( + bandwidth, + 0, + 0, + 0, // TODO: Add compute complexity. + ); }; // See: vms/platformvm/txs/fee/complexity.go:583 @@ -251,6 +290,7 @@ export const addPermissionlessValidatorTx = ( tx: AddPermissionlessValidatorTx, ): Dimensions => { return addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, baseTxComplexity(tx.baseTx), signerComplexity(tx.signer), outputComplexity(tx.stake), @@ -258,3 +298,11 @@ export const addPermissionlessValidatorTx = ( ownerComplexity(tx.getDelegatorRewardsOwner()), ); }; + +export const addSubnetValidatorTx = (tx: AddSubnetValidatorTx): Dimensions => { + return addDimensions( + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + authComplexity(tx.subnetAuth), + ); +}; From 9b99b1e79d0b637a0defd388429e2d9f1d5b0cc8 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 15 Aug 2024 10:19:41 -0600 Subject: [PATCH 04/39] refactor: renaming for better readability --- src/vms/common/fees/dimensions.ts | 2 +- src/vms/pvm/txs/fee/complexity.test.ts | 36 ++++++++++----------- src/vms/pvm/txs/fee/complexity.ts | 45 ++++++++++++++------------ 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/vms/common/fees/dimensions.ts b/src/vms/common/fees/dimensions.ts index a2591c775..99189a313 100644 --- a/src/vms/common/fees/dimensions.ts +++ b/src/vms/common/fees/dimensions.ts @@ -16,7 +16,7 @@ export const getEmptyDimensions = (): Dimensions => ({ [FeeDimensions.Compute]: 0, }); -export const makeDimension = ( +export const makeDimensions = ( bandwidth: DimensionValue, dbRead: DimensionValue, dbWrite: DimensionValue, diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index 7c2de7f61..4c933b7c1 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -15,7 +15,7 @@ import { StakeableLockIn, StakeableLockOut, } from '../../../../serializable/pvm'; -import { makeDimension } from '../../../common/fees/dimensions'; +import { makeDimensions } from '../../../common/fees/dimensions'; import { authComplexity, inputComplexity, @@ -52,25 +52,25 @@ describe('Complexity', () => { test('empty transferable output', () => { const result = outputComplexity([]); - expect(result).toEqual(makeDimension(0, 0, 0, 0)); + expect(result).toEqual(makeDimensions(0, 0, 0, 0)); }); test('any can spend', () => { const result = outputComplexity([makeTransferableOutput()]); - expect(result).toEqual(makeDimension(60, 0, 1, 0)); + expect(result).toEqual(makeDimensions(60, 0, 1, 0)); }); test('one owner', () => { const result = outputComplexity([makeTransferableOutput(1)]); - expect(result).toEqual(makeDimension(80, 0, 1, 0)); + expect(result).toEqual(makeDimensions(80, 0, 1, 0)); }); test('three owners', () => { const result = outputComplexity([makeTransferableOutput(3)]); - expect(result).toEqual(makeDimension(120, 0, 1, 0)); + expect(result).toEqual(makeDimensions(120, 0, 1, 0)); }); test('locked stakeable', () => { @@ -84,7 +84,7 @@ describe('Complexity', () => { ), ]); - expect(result).toEqual(makeDimension(132, 0, 1, 0)); + expect(result).toEqual(makeDimensions(132, 0, 1, 0)); }); }); @@ -93,7 +93,7 @@ describe('Complexity', () => { const result = inputComplexity([makeTransferableInput()]); expect(result).toEqual( - makeDimension( + makeDimensions( 92, 1, 1, @@ -106,7 +106,7 @@ describe('Complexity', () => { const result = inputComplexity([makeTransferableInput(1)]); expect(result).toEqual( - makeDimension( + makeDimensions( 161, 1, 1, @@ -119,7 +119,7 @@ describe('Complexity', () => { const result = inputComplexity([makeTransferableInput(3)]); expect(result).toEqual( - makeDimension( + makeDimensions( 299, 1, 1, @@ -141,7 +141,7 @@ describe('Complexity', () => { ]); expect(result).toEqual( - makeDimension( + makeDimensions( 311, 1, 1, @@ -155,19 +155,19 @@ describe('Complexity', () => { test('any can spend', () => { const result = ownerComplexity(makeOutputOwners()); - expect(result).toEqual(makeDimension(16, 0, 0, 0)); + expect(result).toEqual(makeDimensions(16, 0, 0, 0)); }); test('one owner', () => { const result = ownerComplexity(makeOutputOwners(1)); - expect(result).toEqual(makeDimension(36, 0, 0, 0)); + expect(result).toEqual(makeDimensions(36, 0, 0, 0)); }); test('three owners', () => { const result = ownerComplexity(makeOutputOwners(3)); - expect(result).toEqual(makeDimension(76, 0, 0, 0)); + expect(result).toEqual(makeDimensions(76, 0, 0, 0)); }); }); @@ -175,14 +175,14 @@ describe('Complexity', () => { test('empty signer', () => { const result = signerComplexity(new SignerEmpty()); - expect(result).toEqual(makeDimension(0, 0, 0, 0)); + expect(result).toEqual(makeDimensions(0, 0, 0, 0)); }); test('bls pop', () => { const result = signerComplexity(signer()); expect(result).toEqual( - makeDimension( + makeDimensions( 144, 0, 0, @@ -198,7 +198,7 @@ describe('Complexity', () => { const result = authComplexity(new Input([])); expect(result).toEqual( - makeDimension( + makeDimensions( 8, 0, 0, @@ -211,7 +211,7 @@ describe('Complexity', () => { const result = authComplexity(new Input([int()])); expect(result).toEqual( - makeDimension( + makeDimensions( 77, 0, 0, @@ -224,7 +224,7 @@ describe('Complexity', () => { const result = authComplexity(new Input(ints())); expect(result).toEqual( - makeDimension( + makeDimensions( 215, 0, 0, diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index f0655c7b1..f1fc7536d 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -27,7 +27,7 @@ import { FeeDimensions, addDimensions, getEmptyDimensions, - makeDimension, + makeDimensions, } from '../../../common/fees/dimensions'; import type { Serializable } from '../../../common/types'; @@ -147,10 +147,12 @@ const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { /** * Returns the complexity outputs add to a transaction. */ -export const outputComplexity = (output: TransferableOutput[]): Dimensions => { +export const outputComplexity = ( + transferableOutputs: TransferableOutput[], +): Dimensions => { let complexity = getEmptyDimensions(); - for (const out of output) { + for (const transferableOutput of transferableOutputs) { // outputComplexity logic const outComplexity: Dimensions = { [FeeDimensions.Bandwidth]: @@ -163,12 +165,13 @@ export const outputComplexity = (output: TransferableOutput[]): Dimensions => { let numberOfAddresses = 0; // TODO: Double check this if logic. - if (isStakeableLockOut(out.output)) { + if (isStakeableLockOut(transferableOutput.output)) { outComplexity[FeeDimensions.Bandwidth] += INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH; - numberOfAddresses = out.output.getOutputOwners().addrs.length; - } else if (isTransferOut(out.output)) { - numberOfAddresses = out.output.outputOwners.addrs.length; + numberOfAddresses = + transferableOutput.output.getOutputOwners().addrs.length; + } else if (isTransferOut(transferableOutput.output)) { + numberOfAddresses = transferableOutput.output.outputOwners.addrs.length; } const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; @@ -187,10 +190,12 @@ export const outputComplexity = (output: TransferableOutput[]): Dimensions => { * * It includes the complexity that the corresponding credentials will add. */ -export const inputComplexity = (inputs: TransferableInput[]): Dimensions => { +export const inputComplexity = ( + transferableInputs: TransferableInput[], +): Dimensions => { let complexity = getEmptyDimensions(); - for (const input of inputs) { + for (const transferableInput of transferableInputs) { const inputComplexity: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_INPUT_BANDWIDTH + @@ -201,12 +206,12 @@ export const inputComplexity = (inputs: TransferableInput[]): Dimensions => { }; // TODO: Double check this if logic. - if (isStakeableLockIn(input.input)) { + if (isStakeableLockIn(transferableInput.input)) { inputComplexity[FeeDimensions.Bandwidth] += INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH; } - const numberOfSignatures = input.sigIndicies().length; + const numberOfSignatures = transferableInput.sigIndicies().length; const signatureBandwidth = numberOfSignatures * INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH; @@ -225,7 +230,7 @@ export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { return getEmptyDimensions(); } - return makeDimension( + return makeDimensions( INTRINSIC_POP_BANDWIDTH, 0, 0, @@ -233,14 +238,14 @@ export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { ); }; -export const ownerComplexity = (owner: OutputOwners): Dimensions => { - const numberOfAddresses = owner.addrs.length; +export const ownerComplexity = (outputOwners: OutputOwners): Dimensions => { + const numberOfAddresses = outputOwners.addrs.length; const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; const bandwidth = addressBandwidth + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; - return makeDimension(bandwidth, 0, 0, 0); + return makeDimensions(bandwidth, 0, 0, 0); }; /** @@ -264,7 +269,7 @@ export const authComplexity = (input: Serializable): Dimensions => { const bandwidth = signatureBandwidth + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; - return makeDimension( + return makeDimensions( bandwidth, 0, 0, @@ -273,15 +278,15 @@ export const authComplexity = (input: Serializable): Dimensions => { }; // See: vms/platformvm/txs/fee/complexity.go:583 -const baseTxComplexity = (tx: BaseTx): Dimensions => { - const outputsComplexity = outputComplexity(tx.outputs); - const inputsComplexity = inputComplexity(tx.inputs); +const baseTxComplexity = (baseTx: BaseTx): Dimensions => { + const outputsComplexity = outputComplexity(baseTx.outputs); + const inputsComplexity = inputComplexity(baseTx.inputs); const complexity = addDimensions(outputsComplexity, inputsComplexity); // TODO: Verify if .toBytes().length is correct. // See: vms/platformvm/txs/fee/complexity.go:598 - complexity[FeeDimensions.Bandwidth] += tx.memo.toBytes().length; + complexity[FeeDimensions.Bandwidth] += baseTx.memo.toBytes().length; return getEmptyDimensions(); }; From eb04ec914d76184bc56fc55c2065aa0d952b109e Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 15 Aug 2024 11:19:03 -0600 Subject: [PATCH 05/39] feat: add txComplexity --- src/serializable/primitives/bytes.ts | 5 + src/vms/pvm/txs/fee/complexity.ts | 208 ++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 7 deletions(-) diff --git a/src/serializable/primitives/bytes.ts b/src/serializable/primitives/bytes.ts index 7f8362f91..66e2853d8 100644 --- a/src/serializable/primitives/bytes.ts +++ b/src/serializable/primitives/bytes.ts @@ -33,4 +33,9 @@ export class Bytes extends Primitives { toBytes() { return concatBytes(bytesForInt(this.bytes.length), this.bytes); } + + // TODO: Is this okay or is there some other way of getting the length that is preferred? + get length() { + return this.bytes.length; + } } diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index f1fc7536d..0288d1f1f 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -6,17 +6,36 @@ import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; import type { OutputOwners } from '../../../../serializable'; import { NodeId } from '../../../../serializable'; import { Input } from '../../../../serializable/fxs/secp256k1'; -import type { - BaseTx, - TransferableInput, - TransferableOutput, +import { + isBaseTx, + type BaseTx, + type TransferableInput, + type TransferableOutput, } from '../../../../serializable/avax'; import type { + AddPermissionlessDelegatorTx, AddPermissionlessValidatorTx, AddSubnetValidatorTx, + CreateChainTx, + CreateSubnetTx, + ExportTx, + ImportTx, + RemoveSubnetValidatorTx, Signer, + TransferSubnetOwnershipTx, +} from '../../../../serializable/pvm'; +import { + SignerEmpty, + isAddPermissionlessDelegatorTx, + isAddPermissionlessValidatorTx, + isAddSubnetValidatorTx, + isCreateChainTx, + isCreateSubnetTx, + isExportTx, + isImportTx, + isRemoveSubnetValidatorTx, + isTransferSubnetOwnershipTx, } from '../../../../serializable/pvm'; -import { SignerEmpty } from '../../../../serializable/pvm'; import { isStakeableLockIn, isStakeableLockOut, @@ -30,6 +49,7 @@ import { makeDimensions, } from '../../../common/fees/dimensions'; import type { Serializable } from '../../../common/types'; +import type { Transaction } from '../../../common'; /** * Number of bytes per long. @@ -38,6 +58,11 @@ const LONG_LEN = 8; const ID_LEN = 32; +/** + * Number of bytes per short. + */ +const SHORT_LEN = 2; + const SHORT_ID_LEN = 20; /** @@ -118,6 +143,21 @@ const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; +const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // Subnet ID + SHORT_LEN + // Chain name length + ID_LEN + // vmID + INT_LEN + // num fIds + INT_LEN + // genesis length + INT_LEN + // subnetAuth typeID + INT_LEN, // subnetAuthCredential typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + @@ -133,6 +173,18 @@ const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; +const INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Num stake outs + INT_LEN, // Delegator rewards typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + @@ -144,6 +196,50 @@ const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; +const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // destination chain ID + INT_LEN, // num exported outputs + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // source chain ID + INT_LEN, // num imported inputs + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + SHORT_ID_LEN + // nodeID + ID_LEN + // subnetID + INT_LEN + // subnetAuth typeId + INT_LEN, // subnetAuth credential typeId + [FeeDimensions.DBRead]: 2, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +const INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // subnetID + INT_LEN + // subnetAuth typeID + INT_LEN + // owner typeID + INT_LEN, // subnetAuth credential typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + /** * Returns the complexity outputs add to a transaction. */ @@ -291,7 +387,7 @@ const baseTxComplexity = (baseTx: BaseTx): Dimensions => { return getEmptyDimensions(); }; -export const addPermissionlessValidatorTx = ( +const addPermissionlessValidatorTx = ( tx: AddPermissionlessValidatorTx, ): Dimensions => { return addDimensions( @@ -304,10 +400,108 @@ export const addPermissionlessValidatorTx = ( ); }; -export const addSubnetValidatorTx = (tx: AddSubnetValidatorTx): Dimensions => { +const addPermissionlessDelegatorTx = ( + tx: AddPermissionlessDelegatorTx, +): Dimensions => { + return addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + ownerComplexity(tx.getDelegatorRewardsOwner()), + outputComplexity(tx.stake), + ); +}; + +const addSubnetValidatorTx = (tx: AddSubnetValidatorTx): Dimensions => { return addDimensions( INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, baseTxComplexity(tx.baseTx), authComplexity(tx.subnetAuth), ); }; + +const baseTx = (tx: BaseTx): Dimensions => { + return addDimensions(INTRINSIC_BASE_TX_COMPLEXITIES, baseTxComplexity(tx)); +}; + +const createChainTx = (tx: CreateChainTx): Dimensions => { + let bandwidth: number = tx.fxIds.length * ID_LEN; + bandwidth += tx.chainName.value().length; + bandwidth += tx.genesisData.length; + + const dynamicComplexity = makeDimensions(bandwidth, 0, 0, 0); + + return addDimensions( + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + dynamicComplexity, + baseTxComplexity(tx.baseTx), + authComplexity(tx.subnetAuth), + ); +}; + +const createSubnetTx = (tx: CreateSubnetTx): Dimensions => { + return addDimensions( + baseTxComplexity(tx.baseTx), + ownerComplexity(tx.getSubnetOwners()), + ); +}; + +const exportTx = (tx: ExportTx): Dimensions => { + return addDimensions( + INTRINSIC_EXPORT_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + outputComplexity(tx.outs), + ); +}; + +const importTx = (tx: ImportTx): Dimensions => { + return addDimensions( + INTRINSIC_IMPORT_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + inputComplexity(tx.ins), + ); +}; + +const removeSubnetValidatorTx = (tx: RemoveSubnetValidatorTx): Dimensions => { + return addDimensions( + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + authComplexity(tx.subnetAuth), + ); +}; + +const transferSubnetOwnershipTx = ( + tx: TransferSubnetOwnershipTx, +): Dimensions => { + return addDimensions( + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + authComplexity(tx.subnetAuth), + ownerComplexity(tx.getSubnetOwners()), + ); +}; + +export const txComplexity = (tx: Transaction): Dimensions => { + if (isAddPermissionlessValidatorTx(tx)) { + return addPermissionlessValidatorTx(tx); + } else if (isAddPermissionlessDelegatorTx(tx)) { + return addPermissionlessDelegatorTx(tx); + } else if (isAddSubnetValidatorTx(tx)) { + return addSubnetValidatorTx(tx); + } else if (isCreateChainTx(tx)) { + return createChainTx(tx); + } else if (isCreateSubnetTx(tx)) { + return createSubnetTx(tx); + } else if (isExportTx(tx)) { + return exportTx(tx); + } else if (isImportTx(tx)) { + return importTx(tx); + } else if (isRemoveSubnetValidatorTx(tx)) { + return removeSubnetValidatorTx(tx); + } else if (isTransferSubnetOwnershipTx(tx)) { + return transferSubnetOwnershipTx(tx); + } else if (isBaseTx(tx)) { + return baseTx(tx); + } else { + throw new Error('Unsupported transaction type.'); + } +}; From 0e9022741a6ce42b556c5d0d4af1819bc9f80074 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 16 Aug 2024 13:35:33 -0600 Subject: [PATCH 06/39] fix: complexity calculations and associated tests --- src/serializable/primitives/bytes.ts | 5 + src/vms/pvm/txs/fee/complexity.test.ts | 250 ++++++++++++++++++++++++- src/vms/pvm/txs/fee/complexity.ts | 101 +++++----- 3 files changed, 309 insertions(+), 47 deletions(-) diff --git a/src/serializable/primitives/bytes.ts b/src/serializable/primitives/bytes.ts index 66e2853d8..947bb82d0 100644 --- a/src/serializable/primitives/bytes.ts +++ b/src/serializable/primitives/bytes.ts @@ -35,6 +35,11 @@ export class Bytes extends Primitives { } // TODO: Is this okay or is there some other way of getting the length that is preferred? + /** + * Returns the length of the bytes (Uint8Array). + * + * Useful for calculating tx complexity. + */ get length() { return this.bytes.length; } diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index 4c933b7c1..29f053182 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -15,13 +15,27 @@ import { StakeableLockIn, StakeableLockOut, } from '../../../../serializable/pvm'; -import { makeDimensions } from '../../../common/fees/dimensions'; +import { hexToBuffer, unpackWithManager } from '../../../../utils'; +import { FeeDimensions, makeDimensions } from '../../../common/fees/dimensions'; import { + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_INPUT_DB_READ, + INTRINSIC_INPUT_DB_WRITE, + INTRINSIC_OUTPUT_DB_WRITE, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, authComplexity, inputComplexity, outputComplexity, ownerComplexity, signerComplexity, + txComplexity, } from './complexity'; const makeOutputOwners = (numOfAddresses = 0) => @@ -241,4 +255,238 @@ describe('Complexity', () => { ); }); }); + + describe('txComplexity', () => { + const vm = 'PVM'; + + test.each([ + { + name: 'BaseTx', + txHex: + '00000000002200003039000000000000000000000000000000000000000000000000000000000000000000000002dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fadbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834ed587af80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001fa4ff39749d44f29563ed9da03193d4a19ef419da4ce326594817ca266fda5ed00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1131bbc00000000100000000000000000000000100000009000000014a7b54c63dd25a532b5fe5045b6d0e1db876e067422f12c9c327333c2c792d9273405ac8bbbc2cce549bbd3d0f9274242085ee257adfdb859b0f8d55bdd16fb000', + expectedComplexity: makeDimensions( + 399, + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'AddPermissionlessValidatorTx for primary network', + txHex: + '00000000001900003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba8b1e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f263d53e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae35f0000000066b692df000001d1a94a200000000000000000000000000000000000000000000000000000000000000000000000001ca3783a891cb41cadbfcf456da149f30e7af972677a162b984bef0779f254baac51ec042df1781d1295df80fb41c801269731fc6c25e1e5940dc3cb8509e30348fa712742cfdc83678acc9f95908eb98b89b28802fb559b4a2a6ff3216707c07f0ceb0b45a95f4f9a9540bbd3331d8ab4f233bffa4abb97fad9d59a1695f31b92a2b89e365facf7ab8c30de7c4a496d1e00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0007a12000000001000000090000000135f122f90bcece0d6c43e07fed1829578a23bc1734f8a4b46203f9f192ea1aec7526f3dca8fddec7418988615e6543012452bae1544275aae435313ec006ec9000', + expectedComplexity: makeDimensions( + 691, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'AddPermissionlessValidatorTx for subnet', + txHex: + '000000000019000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006091000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba6c9980000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000002038b42b73d3dc695c76ca12f966e97fe0681b1200f9a5e28d088720a18ea23c9000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609b0000000100000000a378b74b3293a9d885bd9961f2cc2e1b3364d393c9be875964f2bd614214572c00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba7bdbc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57a160000000066b7ef16000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000001b000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000000000000000000000b00000000000000000000000000000000000f4240000000020000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae6000000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae600', + expectedComplexity: makeDimensions( + 748, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 2 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 2 * INTRINSIC_INPUT_DB_WRITE + + 3 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'AddPermissionlessDelegatorTx for primary network', + txHex: + '00000000001a00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1140fe00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000017d199179744b3b82d0071c83c2fb7dd6b95a2cdbe9dde295e0ae4f8c2287370300000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba8b1e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae6080000000066ad5b08000001d1a94a2000000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000100000009000000012261556f74a29f02ffc2725a567db2c81f75d0892525dbebaa1cf8650534cc70061123533a9553184cb02d899943ff0bf0b39c77b173c133854bc7c8bc7ab9a400', + expectedComplexity: makeDimensions( + 499, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 1 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 1 * INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'AddPermissionlessDelegatorTx for subnet', + txHex: + '00000000001a000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006087000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700470c1336195b80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000029494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609100000001000000009494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500470c1336289dc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57c1d0000000066b7f11d000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c124000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b00000000000000000000000000000000000000020000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a000000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a00', + expectedComplexity: makeDimensions( + 720, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 2 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 2 * INTRINSIC_INPUT_DB_WRITE + + 3 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'AddSubnetValidatorTx', + txHex: + '00000000000d00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1131bbc0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000138f94d1a0514eaabdaf4c52cad8d62b26cee61eaa951f5b75a5e57c2ee3793c800000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1140fe00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae7c90000000066ad5cc9000000000000c13797ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000200000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb60000000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb600', + expectedComplexity: makeDimensions( + 460, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'CreateChainTx', + txHex: + '00000000000f00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f263d53e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000197ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f269cb1f0000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400096c65742074686572657873766d00000000000000000000000000000000000000000000000000000000000000000000002a000000000000669ae21e000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cffffffffffffffff0000000a0000000100000000000000020000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f8010000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f801', + expectedComplexity: makeDimensions( + 509, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'CreateSubnetTx', + txHex: + '00000000001000003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f269cb1f00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc100000000000100000000000000000000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000010000000900000001b3c905e7227e619bd6b98c164a8b2b4a8ce89ac5142bbb1c42b139df2d17fd777c4c76eae66cef3de90800e567407945f58d918978f734f8ca4eda6923c78eb201', + expectedComplexity: makeDimensions( + 339, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'ExportTx', + txHex: + '00000000001200003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99dda340000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001f62c03574790b6a31a988f90c3e91c50fdd6f5d93baf200057463021ff23ec5c00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834ed587af800000000100000000000000009d0775f450604bd2fbc49ce0c5c1c6dfeb2dc2acb8c92c26eeae6e6df4502b1900000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fa000000010000000900000001129a07c92045e0b9d0a203fcb5b53db7890fabce1397ff6a2ad16c98ef0151891ae72949d240122abf37b1206b95e05ff171df164a98e6bdf2384432eac2c30200', + expectedComplexity: makeDimensions( + 435, + INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'ImportTx', + txHex: + '00000000001100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b8b87c0000000000000000100000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000000000000d891ad56056d9c01f18f43f58b5c784ad07a4a49cf3d1f11623804b5cba2c6bf0000000163684415710a7d65f4ccb095edff59f897106b94d38937fc60e3ffc29892833b00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005000000003b9aca00000000010000000000000001000000090000000148ea12cb0950e47d852b99765208f5a811d3c8a47fa7b23fd524bd970019d157029f973abb91c31a146752ef8178434deb331db24c8dca5e61c961e6ac2f3b6700', + expectedComplexity: makeDimensions( + 335, + INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + + { + name: 'TransferSubnetOwnershipTx', + txHex: + '00000000002100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99bf1ec0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000018f6e5f2840e34f9a375f35627a44bb0b9974285d280dc3220aa9489f97b17ebd00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99ce610000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000b00000000000000000000000000000000000000020000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f010000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f01', + expectedComplexity: makeDimensions( + 436, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + INTRINSIC_INPUT_DB_READ, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + }, + ])('$name', ({ txHex, expectedComplexity }) => { + const txBytes = hexToBuffer(txHex); + + // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); + + const tx = unpackWithManager(vm, txBytes); + + const result = txComplexity(tx); + + expect(result).toEqual(expectedComplexity); + }); + + test.each([ + { + name: 'AddDelegatorTx', + txHash: + '00000000000e000000050000000000000000000000000000000000000000000000000000000000000000000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000007000000003b9aca0000000000000000000000000100000001f887b4c7030e95d2495603ae5d8b14cc0a66781a000000011767be999a49ca24fe705de032fa613b682493110fd6468ae7fb56bde1b9d729000000003d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000005000000012a05f20000000001000000000000000400000000c51c552c49174e2e18b392049d3e4cd48b11490f000000005f692452000000005f73b05200000000ee6b2800000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000700000000ee6b280000000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb0000000b00000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb00000001000000090000000135cd78758035ed528d230317e5d880083a86a2b68c4a95655571828fe226548f235031c8dabd1fe06366a57613c4370ac26c4c59d1a1c46287a59906ec41b88f00', + }, + + { + name: 'AddValidatorTx', + txHash: + '00000000000c0000000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000f4b21e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000015000000006134088000000005000001d1a94a200000000001000000000000000400000000b3da694c70b8bee4478051313621c3f2282088b4000000005f6976d500000000614aaa19000001d1a94a20000000000121e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000016000000006134088000000007000001d1a94a20000000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c40000000b0000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c400009c40000000010000000900000001620513952dd17c8726d52e9e621618cb38f09fd194abb4cd7b4ee35ecd10880a562ad968dc81a89beab4e87d88d5d582aa73d0d265c87892d1ffff1f6e00f0ef00', + }, + + { + name: 'RewardValidatorTx', + txHash: + '0000000000143d0ad12b8ee8928edf248ca91ca55600fb383f07c32bff1d6dec472b25cf59a700000000', + }, + + { + name: 'AdvanceTimeTx', + txHash: '0000000000130000000066a56fe700000000', + }, + ])('unsupported tx - $name', ({ txHash }) => { + const txBytes = hexToBuffer(txHash); + + const tx = unpackWithManager(vm, txBytes); + + expect(() => { + txComplexity(tx); + }).toThrow('Unsupported transaction type.'); + }); + }); }); diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 0288d1f1f..8e123b386 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -4,10 +4,8 @@ import { } from '../../../../crypto/bls'; import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; import type { OutputOwners } from '../../../../serializable'; -import { NodeId } from '../../../../serializable'; import { Input } from '../../../../serializable/fxs/secp256k1'; import { - isBaseTx, type BaseTx, type TransferableInput, type TransferableOutput, @@ -16,6 +14,7 @@ import type { AddPermissionlessDelegatorTx, AddPermissionlessValidatorTx, AddSubnetValidatorTx, + BaseTx as PvmBaseTx, CreateChainTx, CreateSubnetTx, ExportTx, @@ -33,6 +32,7 @@ import { isCreateSubnetTx, isExportTx, isImportTx, + isPvmBaseTx, isRemoveSubnetValidatorTx, isTransferSubnetOwnershipTx, } from '../../../../serializable/pvm'; @@ -71,14 +71,14 @@ const SHORT_ID_LEN = 20; const INT_LEN = 4; const INTRINSIC_VALIDATOR_BANDWIDTH = - NodeId.length + // Node ID (Short ID = 20) + SHORT_ID_LEN + // Node ID (Short ID = 20) LONG_LEN + // Start LONG_LEN + // End LONG_LEN; // Weight const INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH = INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - +ID_LEN; // Subnet ID (ID Length = 32) + ID_LEN; // Subnet ID (ID Length = 32) const INTRINSIC_OUTPUT_BANDWIDTH = ID_LEN + // assetID @@ -124,11 +124,11 @@ const INTRINSIC_POP_BANDWIDTH = PUBLIC_KEY_LENGTH + // Public key BLS_SIGNATURE_LENGTH; // Signature -const INTRINSIC_INPUT_DB_READ = 1; -const INTRINSIC_INPUT_DB_WRITE = 1; -const INTRINSIC_OUTPUT_DB_WRITE = 1; +export const INTRINSIC_INPUT_DB_READ = 1; +export const INTRINSIC_INPUT_DB_WRITE = 1; +export const INTRINSIC_OUTPUT_DB_WRITE = 1; -const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: 2 + // codec version INT_LEN + // typeID @@ -143,7 +143,7 @@ const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + ID_LEN + // Subnet ID @@ -158,34 +158,44 @@ const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - ID_LEN + // Subnet ID - INT_LEN + // Signer typeID - INT_LEN + // Num stake outs - INT_LEN + // Validator rewards typeID - INT_LEN + // Delegator rewards typeID - INT_LEN, // Delegation shares - [FeeDimensions.DBRead]: 1, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; - -const INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - ID_LEN + // Subnet ID - INT_LEN + // Num stake outs - INT_LEN, // Delegator rewards typeID - [FeeDimensions.DBRead]: 1, + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + INT_LEN, // owner typeID + [FeeDimensions.DBRead]: 0, [FeeDimensions.DBWrite]: 1, [FeeDimensions.Compute]: 0, }; -const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = + { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Signer typeID + INT_LEN + // Num stake outs + INT_LEN + // Validator rewards typeID + INT_LEN + // Delegator rewards typeID + INT_LEN, // Delegation shares + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, + }; + +export const INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES: Dimensions = + { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Num stake outs + INT_LEN, // Delegator rewards typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, + }; + +export const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH + // Subnet Validator @@ -196,7 +206,7 @@ const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + ID_LEN + // destination chain ID @@ -206,7 +216,7 @@ const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + ID_LEN + // source chain ID @@ -216,7 +226,7 @@ const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + SHORT_ID_LEN + // nodeID @@ -228,7 +238,7 @@ const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Compute]: 0, }; -const INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES: Dimensions = { +export const INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + ID_LEN + // subnetID @@ -260,7 +270,6 @@ export const outputComplexity = ( let numberOfAddresses = 0; - // TODO: Double check this if logic. if (isStakeableLockOut(transferableOutput.output)) { outComplexity[FeeDimensions.Bandwidth] += INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH; @@ -301,7 +310,6 @@ export const inputComplexity = ( [FeeDimensions.Compute]: 0, // TODO: Add compute complexity. }; - // TODO: Double check this if logic. if (isStakeableLockIn(transferableInput.input)) { inputComplexity[FeeDimensions.Bandwidth] += INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH; @@ -373,18 +381,15 @@ export const authComplexity = (input: Serializable): Dimensions => { ); }; -// See: vms/platformvm/txs/fee/complexity.go:583 const baseTxComplexity = (baseTx: BaseTx): Dimensions => { const outputsComplexity = outputComplexity(baseTx.outputs); const inputsComplexity = inputComplexity(baseTx.inputs); const complexity = addDimensions(outputsComplexity, inputsComplexity); - // TODO: Verify if .toBytes().length is correct. - // See: vms/platformvm/txs/fee/complexity.go:598 - complexity[FeeDimensions.Bandwidth] += baseTx.memo.toBytes().length; + complexity[FeeDimensions.Bandwidth] += baseTx.memo.length; - return getEmptyDimensions(); + return complexity; }; const addPermissionlessValidatorTx = ( @@ -419,8 +424,11 @@ const addSubnetValidatorTx = (tx: AddSubnetValidatorTx): Dimensions => { ); }; -const baseTx = (tx: BaseTx): Dimensions => { - return addDimensions(INTRINSIC_BASE_TX_COMPLEXITIES, baseTxComplexity(tx)); +const baseTx = (tx: PvmBaseTx): Dimensions => { + return addDimensions( + INTRINSIC_BASE_TX_COMPLEXITIES, + baseTxComplexity(tx.baseTx), + ); }; const createChainTx = (tx: CreateChainTx): Dimensions => { @@ -440,6 +448,7 @@ const createChainTx = (tx: CreateChainTx): Dimensions => { const createSubnetTx = (tx: CreateSubnetTx): Dimensions => { return addDimensions( + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, baseTxComplexity(tx.baseTx), ownerComplexity(tx.getSubnetOwners()), ); @@ -499,7 +508,7 @@ export const txComplexity = (tx: Transaction): Dimensions => { return removeSubnetValidatorTx(tx); } else if (isTransferSubnetOwnershipTx(tx)) { return transferSubnetOwnershipTx(tx); - } else if (isBaseTx(tx)) { + } else if (isPvmBaseTx(tx)) { return baseTx(tx); } else { throw new Error('Unsupported transaction type.'); From 3977b688edd49172a5589c38f2e51585553216b8 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 16 Aug 2024 13:43:34 -0600 Subject: [PATCH 07/39] refactor: move complexity constants to new file --- src/vms/pvm/txs/fee/complexity.test.ts | 16 +- src/vms/pvm/txs/fee/complexity.ts | 231 +++---------------------- src/vms/pvm/txs/fee/constants.ts | 206 ++++++++++++++++++++++ 3 files changed, 242 insertions(+), 211 deletions(-) create mode 100644 src/vms/pvm/txs/fee/constants.ts diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index 29f053182..5856769c4 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -17,6 +17,14 @@ import { } from '../../../../serializable/pvm'; import { hexToBuffer, unpackWithManager } from '../../../../utils'; import { FeeDimensions, makeDimensions } from '../../../common/fees/dimensions'; +import { + authComplexity, + inputComplexity, + outputComplexity, + ownerComplexity, + signerComplexity, + txComplexity, +} from './complexity'; import { INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, @@ -30,13 +38,7 @@ import { INTRINSIC_INPUT_DB_WRITE, INTRINSIC_OUTPUT_DB_WRITE, INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, - authComplexity, - inputComplexity, - outputComplexity, - ownerComplexity, - signerComplexity, - txComplexity, -} from './complexity'; +} from './constants'; const makeOutputOwners = (numOfAddresses = 0) => new OutputOwners( diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 8e123b386..68b3a6c03 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -1,8 +1,3 @@ -import { - PUBLIC_KEY_LENGTH, - SIGNATURE_LENGTH as BLS_SIGNATURE_LENGTH, -} from '../../../../crypto/bls'; -import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; import type { OutputOwners } from '../../../../serializable'; import { Input } from '../../../../serializable/fxs/secp256k1'; import { @@ -50,205 +45,33 @@ import { } from '../../../common/fees/dimensions'; import type { Serializable } from '../../../common/types'; import type { Transaction } from '../../../common'; - -/** - * Number of bytes per long. - */ -const LONG_LEN = 8; - -const ID_LEN = 32; - -/** - * Number of bytes per short. - */ -const SHORT_LEN = 2; - -const SHORT_ID_LEN = 20; - -/** - * Number of bytes per int. - */ -const INT_LEN = 4; - -const INTRINSIC_VALIDATOR_BANDWIDTH = - SHORT_ID_LEN + // Node ID (Short ID = 20) - LONG_LEN + // Start - LONG_LEN + // End - LONG_LEN; // Weight - -const INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH = - INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - ID_LEN; // Subnet ID (ID Length = 32) - -const INTRINSIC_OUTPUT_BANDWIDTH = - ID_LEN + // assetID - INT_LEN; // output typeID - -const INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH = - LONG_LEN + // locktime - INT_LEN; // output typeID - -const INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH = - LONG_LEN + // locktime - INT_LEN + // threshold - INT_LEN; // number of addresses - -const INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH = - LONG_LEN + // amount - INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; - -const INTRINSIC_INPUT_BANDWIDTH = - ID_LEN + // txID - INT_LEN + // output index - ID_LEN + // assetID - INT_LEN + // input typeID - INT_LEN; // credential typeID - -const INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH = - LONG_LEN + // locktime - INT_LEN; // input typeID - -const INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH = - INT_LEN + // num indices - INT_LEN; // num signatures - -const INTRINSIC_SECP256K1_FX_TRANSFERABLE_INPUT_BANDWIDTH = - LONG_LEN + // amount - INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; - -const INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH = - INT_LEN + // Signature index - SIGNATURE_LENGTH; // Signature - -const INTRINSIC_POP_BANDWIDTH = - PUBLIC_KEY_LENGTH + // Public key - BLS_SIGNATURE_LENGTH; // Signature - -export const INTRINSIC_INPUT_DB_READ = 1; -export const INTRINSIC_INPUT_DB_WRITE = 1; -export const INTRINSIC_OUTPUT_DB_WRITE = 1; - -export const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - 2 + // codec version - INT_LEN + // typeID - INT_LEN + // networkID - ID_LEN + // blockchainID - INT_LEN + // number of outputs - INT_LEN + // number of inputs - INT_LEN + // length of memo - INT_LEN, // number of credentials - [FeeDimensions.DBRead]: 0, - [FeeDimensions.DBWrite]: 0, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - ID_LEN + // Subnet ID - SHORT_LEN + // Chain name length - ID_LEN + // vmID - INT_LEN + // num fIds - INT_LEN + // genesis length - INT_LEN + // subnetAuth typeID - INT_LEN, // subnetAuthCredential typeID - [FeeDimensions.DBRead]: 1, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + INT_LEN, // owner typeID - [FeeDimensions.DBRead]: 0, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = - { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - ID_LEN + // Subnet ID - INT_LEN + // Signer typeID - INT_LEN + // Num stake outs - INT_LEN + // Validator rewards typeID - INT_LEN + // Delegator rewards typeID - INT_LEN, // Delegation shares - [FeeDimensions.DBRead]: 1, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, - }; - -export const INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES: Dimensions = - { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - INTRINSIC_VALIDATOR_BANDWIDTH + // Validator - ID_LEN + // Subnet ID - INT_LEN + // Num stake outs - INT_LEN, // Delegator rewards typeID - [FeeDimensions.DBRead]: 1, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, - }; - -export const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH + // Subnet Validator - INT_LEN + // Subnet auth typeID - INT_LEN, // Subnet auth credential typeID - [FeeDimensions.DBRead]: 2, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - ID_LEN + // destination chain ID - INT_LEN, // num exported outputs - [FeeDimensions.DBRead]: 0, - [FeeDimensions.DBWrite]: 0, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - ID_LEN + // source chain ID - INT_LEN, // num imported inputs - [FeeDimensions.DBRead]: 0, - [FeeDimensions.DBWrite]: 0, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - SHORT_ID_LEN + // nodeID - ID_LEN + // subnetID - INT_LEN + // subnetAuth typeId - INT_LEN, // subnetAuth credential typeId - [FeeDimensions.DBRead]: 2, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; - -export const INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES: Dimensions = { - [FeeDimensions.Bandwidth]: - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + - ID_LEN + // subnetID - INT_LEN + // subnetAuth typeID - INT_LEN + // owner typeID - INT_LEN, // subnetAuth credential typeID - [FeeDimensions.DBRead]: 1, - [FeeDimensions.DBWrite]: 1, - [FeeDimensions.Compute]: 0, -}; +import { + ID_LEN, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_INPUT_BANDWIDTH, + INTRINSIC_INPUT_DB_READ, + INTRINSIC_INPUT_DB_WRITE, + INTRINSIC_OUTPUT_BANDWIDTH, + INTRINSIC_OUTPUT_DB_WRITE, + INTRINSIC_POP_BANDWIDTH, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH, + INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH, + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH, + INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH, + INTRINSIC_SECP256K1_FX_TRANSFERABLE_INPUT_BANDWIDTH, + INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH, + INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + SHORT_ID_LEN, +} from './constants'; /** * Returns the complexity outputs add to a transaction. diff --git a/src/vms/pvm/txs/fee/constants.ts b/src/vms/pvm/txs/fee/constants.ts new file mode 100644 index 000000000..0de22dc83 --- /dev/null +++ b/src/vms/pvm/txs/fee/constants.ts @@ -0,0 +1,206 @@ +import type { Dimensions } from '../../../common/fees/dimensions'; +import { FeeDimensions } from '../../../common/fees/dimensions'; +import { + PUBLIC_KEY_LENGTH, + SIGNATURE_LENGTH as BLS_SIGNATURE_LENGTH, +} from '../../../../crypto/bls'; +import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; + +/** + * Number of bytes per long. + */ +const LONG_LEN = 8; + +export const ID_LEN = 32; + +/** + * Number of bytes per short. + */ +const SHORT_LEN = 2; + +export const SHORT_ID_LEN = 20; + +/** + * Number of bytes per int. + */ +const INT_LEN = 4; + +const INTRINSIC_VALIDATOR_BANDWIDTH = + SHORT_ID_LEN + // Node ID (Short ID = 20) + LONG_LEN + // Start + LONG_LEN + // End + LONG_LEN; // Weight + +const INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH = + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN; // Subnet ID (ID Length = 32) + +export const INTRINSIC_OUTPUT_BANDWIDTH = + ID_LEN + // assetID + INT_LEN; // output typeID + +export const INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN; // output typeID + +export const INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN + // threshold + INT_LEN; // number of addresses + +export const INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH = + LONG_LEN + // amount + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; + +export const INTRINSIC_INPUT_BANDWIDTH = + ID_LEN + // txID + INT_LEN + // output index + ID_LEN + // assetID + INT_LEN + // input typeID + INT_LEN; // credential typeID + +export const INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH = + LONG_LEN + // locktime + INT_LEN; // input typeID + +export const INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH = + INT_LEN + // num indices + INT_LEN; // num signatures + +export const INTRINSIC_SECP256K1_FX_TRANSFERABLE_INPUT_BANDWIDTH = + LONG_LEN + // amount + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; + +export const INTRINSIC_SECP256K1_FX_SIGNATURE_BANDWIDTH = + INT_LEN + // Signature index + SIGNATURE_LENGTH; // Signature + +export const INTRINSIC_POP_BANDWIDTH = + PUBLIC_KEY_LENGTH + // Public key + BLS_SIGNATURE_LENGTH; // Signature + +export const INTRINSIC_INPUT_DB_READ = 1; +export const INTRINSIC_INPUT_DB_WRITE = 1; +export const INTRINSIC_OUTPUT_DB_WRITE = 1; + +export const INTRINSIC_BASE_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + 2 + // codec version + INT_LEN + // typeID + INT_LEN + // networkID + ID_LEN + // blockchainID + INT_LEN + // number of outputs + INT_LEN + // number of inputs + INT_LEN + // length of memo + INT_LEN, // number of credentials + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // Subnet ID + SHORT_LEN + // Chain name length + ID_LEN + // vmID + INT_LEN + // num fIds + INT_LEN + // genesis length + INT_LEN + // subnetAuth typeID + INT_LEN, // subnetAuthCredential typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + INT_LEN, // owner typeID + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES: Dimensions = + { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Signer typeID + INT_LEN + // Num stake outs + INT_LEN + // Validator rewards typeID + INT_LEN + // Delegator rewards typeID + INT_LEN, // Delegation shares + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, + }; + +export const INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES: Dimensions = + { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_VALIDATOR_BANDWIDTH + // Validator + ID_LEN + // Subnet ID + INT_LEN + // Num stake outs + INT_LEN, // Delegator rewards typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, + }; + +export const INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + INTRINSIC_SUBNET_VALIDATOR_BANDWIDTH + // Subnet Validator + INT_LEN + // Subnet auth typeID + INT_LEN, // Subnet auth credential typeID + [FeeDimensions.DBRead]: 2, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_EXPORT_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // destination chain ID + INT_LEN, // num exported outputs + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_IMPORT_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // source chain ID + INT_LEN, // num imported inputs + [FeeDimensions.DBRead]: 0, + [FeeDimensions.DBWrite]: 0, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + SHORT_ID_LEN + // nodeID + ID_LEN + // subnetID + INT_LEN + // subnetAuth typeId + INT_LEN, // subnetAuth credential typeId + [FeeDimensions.DBRead]: 2, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; + +export const INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES: Dimensions = { + [FeeDimensions.Bandwidth]: + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.Bandwidth] + + ID_LEN + // subnetID + INT_LEN + // subnetAuth typeID + INT_LEN + // owner typeID + INT_LEN, // subnetAuth credential typeID + [FeeDimensions.DBRead]: 1, + [FeeDimensions.DBWrite]: 1, + [FeeDimensions.Compute]: 0, +}; From 2651bf53cf9a2f3ef94461192f6e5ab222b606af Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Mon, 19 Aug 2024 11:16:23 -0600 Subject: [PATCH 08/39] feat: add fee calculator --- src/vms/common/fees/dimensions.ts | 12 + src/vms/pvm/txs/fee/calculator.test.ts | 44 +++ src/vms/pvm/txs/fee/calculator.ts | 24 ++ src/vms/pvm/txs/fee/complexity.test.ts | 259 ++---------------- src/vms/pvm/txs/fee/fixtures/transactions.ts | 271 +++++++++++++++++++ 5 files changed, 373 insertions(+), 237 deletions(-) create mode 100644 src/vms/pvm/txs/fee/calculator.test.ts create mode 100644 src/vms/pvm/txs/fee/calculator.ts create mode 100644 src/vms/pvm/txs/fee/fixtures/transactions.ts diff --git a/src/vms/common/fees/dimensions.ts b/src/vms/common/fees/dimensions.ts index 99189a313..c4fb54427 100644 --- a/src/vms/common/fees/dimensions.ts +++ b/src/vms/common/fees/dimensions.ts @@ -48,3 +48,15 @@ export const addDimensions = (...dimensions: Dimensions[]): Dimensions => { } return result; }; + +export const dimensionsToGas = ( + dimensions: Dimensions, + weights: Dimensions, +): bigint => { + return BigInt( + dimensions[FeeDimensions.Bandwidth] * weights[FeeDimensions.Bandwidth] + + dimensions[FeeDimensions.DBRead] * weights[FeeDimensions.DBRead] + + dimensions[FeeDimensions.DBWrite] * weights[FeeDimensions.DBWrite] + + dimensions[FeeDimensions.Compute] * weights[FeeDimensions.Compute], + ); +}; diff --git a/src/vms/pvm/txs/fee/calculator.test.ts b/src/vms/pvm/txs/fee/calculator.test.ts new file mode 100644 index 000000000..6b629f377 --- /dev/null +++ b/src/vms/pvm/txs/fee/calculator.test.ts @@ -0,0 +1,44 @@ +import { hexToBuffer, unpackWithManager } from '../../../../utils'; +import { calculateFee } from './calculator'; +import { + TEST_DYNAMIC_PRICE, + TEST_DYNAMIC_WEIGHTS, + TEST_TRANSACTIONS, + TEST_UNSUPPORTED_TRANSACTIONS, +} from './fixtures/transactions'; + +const txHexToPVMTransaction = (txHex: string) => { + const txBytes = hexToBuffer(txHex); + + // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); + + return unpackWithManager('PVM', txBytes); +}; + +describe('Calculator', () => { + describe('calculateFee', () => { + test.each(TEST_TRANSACTIONS)( + 'calculates the fee for $name', + ({ txHex, expectedDynamicFee }) => { + const result = calculateFee( + txHexToPVMTransaction(txHex), + TEST_DYNAMIC_WEIGHTS, + TEST_DYNAMIC_PRICE, + ); + + expect(result).toBe(expectedDynamicFee); + }, + ); + + test.each(TEST_UNSUPPORTED_TRANSACTIONS)( + 'unsupported tx - $name', + ({ txHex }) => { + const tx = txHexToPVMTransaction(txHex); + + expect(() => { + calculateFee(tx, TEST_DYNAMIC_WEIGHTS, TEST_DYNAMIC_PRICE); + }).toThrow('Unsupported transaction type.'); + }, + ); + }); +}); diff --git a/src/vms/pvm/txs/fee/calculator.ts b/src/vms/pvm/txs/fee/calculator.ts new file mode 100644 index 000000000..fd7038839 --- /dev/null +++ b/src/vms/pvm/txs/fee/calculator.ts @@ -0,0 +1,24 @@ +import type { Transaction } from '../../../common'; +import { + dimensionsToGas, + type Dimensions, +} from '../../../common/fees/dimensions'; +import { txComplexity } from './complexity'; + +/** + * Calculates the minimum required fee, in nAVAX, that an unsigned + * transaction must pay for valid inclusion into a block. + */ +export const calculateFee = ( + // TODO: Do we need this to be UnsignedTx? + // If so, we can use .getTx() to get the Transaction. + tx: Transaction, + weights: Dimensions, + price: bigint, +): bigint => { + const complexity = txComplexity(tx); + + const gas = dimensionsToGas(complexity, weights); + + return gas * price; +}; diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index 5856769c4..f6e5f0394 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -16,7 +16,7 @@ import { StakeableLockOut, } from '../../../../serializable/pvm'; import { hexToBuffer, unpackWithManager } from '../../../../utils'; -import { FeeDimensions, makeDimensions } from '../../../common/fees/dimensions'; +import { makeDimensions } from '../../../common/fees/dimensions'; import { authComplexity, inputComplexity, @@ -26,19 +26,9 @@ import { txComplexity, } from './complexity'; import { - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, - INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, - INTRINSIC_BASE_TX_COMPLEXITIES, - INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, - INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, - INTRINSIC_EXPORT_TX_COMPLEXITIES, - INTRINSIC_IMPORT_TX_COMPLEXITIES, - INTRINSIC_INPUT_DB_READ, - INTRINSIC_INPUT_DB_WRITE, - INTRINSIC_OUTPUT_DB_WRITE, - INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, -} from './constants'; + TEST_TRANSACTIONS, + TEST_UNSUPPORTED_TRANSACTIONS, +} from './fixtures/transactions'; const makeOutputOwners = (numOfAddresses = 0) => new OutputOwners( @@ -63,6 +53,14 @@ const makeTransferableInput = (numOfSigInts = 0) => ), ); +const txHexToPVMTransaction = (txHex: string) => { + const txBytes = hexToBuffer(txHex); + + // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); + + return unpackWithManager('PVM', txBytes); +}; + describe('Complexity', () => { describe('outputComplexity', () => { test('empty transferable output', () => { @@ -259,236 +257,23 @@ describe('Complexity', () => { }); describe('txComplexity', () => { - const vm = 'PVM'; - - test.each([ - { - name: 'BaseTx', - txHex: - '00000000002200003039000000000000000000000000000000000000000000000000000000000000000000000002dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fadbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834ed587af80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001fa4ff39749d44f29563ed9da03193d4a19ef419da4ce326594817ca266fda5ed00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1131bbc00000000100000000000000000000000100000009000000014a7b54c63dd25a532b5fe5045b6d0e1db876e067422f12c9c327333c2c792d9273405ac8bbbc2cce549bbd3d0f9274242085ee257adfdb859b0f8d55bdd16fb000', - expectedComplexity: makeDimensions( - 399, - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBWrite] + - INTRINSIC_INPUT_DB_WRITE + - 2 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'AddPermissionlessValidatorTx for primary network', - txHex: - '00000000001900003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba8b1e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f263d53e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae35f0000000066b692df000001d1a94a200000000000000000000000000000000000000000000000000000000000000000000000001ca3783a891cb41cadbfcf456da149f30e7af972677a162b984bef0779f254baac51ec042df1781d1295df80fb41c801269731fc6c25e1e5940dc3cb8509e30348fa712742cfdc83678acc9f95908eb98b89b28802fb559b4a2a6ff3216707c07f0ceb0b45a95f4f9a9540bbd3331d8ab4f233bffa4abb97fad9d59a1695f31b92a2b89e365facf7ab8c30de7c4a496d1e00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0007a12000000001000000090000000135f122f90bcece0d6c43e07fed1829578a23bc1734f8a4b46203f9f192ea1aec7526f3dca8fddec7418988615e6543012452bae1544275aae435313ec006ec9000', - expectedComplexity: makeDimensions( - 691, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ - FeeDimensions.DBRead - ] + INTRINSIC_INPUT_DB_READ, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - INTRINSIC_INPUT_DB_WRITE + - 2 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'AddPermissionlessValidatorTx for subnet', - txHex: - '000000000019000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006091000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba6c9980000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000002038b42b73d3dc695c76ca12f966e97fe0681b1200f9a5e28d088720a18ea23c9000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609b0000000100000000a378b74b3293a9d885bd9961f2cc2e1b3364d393c9be875964f2bd614214572c00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba7bdbc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57a160000000066b7ef16000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000001b000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000000000000000000000b00000000000000000000000000000000000f4240000000020000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae6000000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae600', - expectedComplexity: makeDimensions( - 748, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ - FeeDimensions.DBRead - ] + - 2 * INTRINSIC_INPUT_DB_READ, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - 2 * INTRINSIC_INPUT_DB_WRITE + - 3 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'AddPermissionlessDelegatorTx for primary network', - txHex: - '00000000001a00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1140fe00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000017d199179744b3b82d0071c83c2fb7dd6b95a2cdbe9dde295e0ae4f8c2287370300000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba8b1e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae6080000000066ad5b08000001d1a94a2000000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000100000009000000012261556f74a29f02ffc2725a567db2c81f75d0892525dbebaa1cf8650534cc70061123533a9553184cb02d899943ff0bf0b39c77b173c133854bc7c8bc7ab9a400', - expectedComplexity: makeDimensions( - 499, - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ - FeeDimensions.DBRead - ] + - 1 * INTRINSIC_INPUT_DB_READ, - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - 1 * INTRINSIC_INPUT_DB_WRITE + - 2 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'AddPermissionlessDelegatorTx for subnet', - txHex: - '00000000001a000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006087000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700470c1336195b80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000029494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609100000001000000009494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500470c1336289dc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57c1d0000000066b7f11d000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c124000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b00000000000000000000000000000000000000020000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a000000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a00', - expectedComplexity: makeDimensions( - 720, - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ - FeeDimensions.DBRead - ] + - 2 * INTRINSIC_INPUT_DB_READ, - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - 2 * INTRINSIC_INPUT_DB_WRITE + - 3 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'AddSubnetValidatorTx', - txHex: - '00000000000d00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1131bbc0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000138f94d1a0514eaabdaf4c52cad8d62b26cee61eaa951f5b75a5e57c2ee3793c800000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1140fe00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae7c90000000066ad5cc9000000000000c13797ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000200000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb60000000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb600', - expectedComplexity: makeDimensions( - 460, - INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - INTRINSIC_INPUT_DB_WRITE + - INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'CreateChainTx', - txHex: - '00000000000f00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f263d53e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000197ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f269cb1f0000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400096c65742074686572657873766d00000000000000000000000000000000000000000000000000000000000000000000002a000000000000669ae21e000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cffffffffffffffff0000000a0000000100000000000000020000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f8010000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f801', - expectedComplexity: makeDimensions( - 509, - INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBWrite] + - INTRINSIC_INPUT_DB_WRITE + - INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'CreateSubnetTx', - txHex: - '00000000001000003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f269cb1f00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc100000000000100000000000000000000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000010000000900000001b3c905e7227e619bd6b98c164a8b2b4a8ce89ac5142bbb1c42b139df2d17fd777c4c76eae66cef3de90800e567407945f58d918978f734f8ca4eda6923c78eb201', - expectedComplexity: makeDimensions( - 339, - INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBWrite] + - INTRINSIC_INPUT_DB_WRITE + - INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'ExportTx', - txHex: - '00000000001200003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99dda340000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001f62c03574790b6a31a988f90c3e91c50fdd6f5d93baf200057463021ff23ec5c00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834ed587af800000000100000000000000009d0775f450604bd2fbc49ce0c5c1c6dfeb2dc2acb8c92c26eeae6e6df4502b1900000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fa000000010000000900000001129a07c92045e0b9d0a203fcb5b53db7890fabce1397ff6a2ad16c98ef0151891ae72949d240122abf37b1206b95e05ff171df164a98e6bdf2384432eac2c30200', - expectedComplexity: makeDimensions( - 435, - INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + - INTRINSIC_INPUT_DB_WRITE + - 2 * INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'ImportTx', - txHex: - '00000000001100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b8b87c0000000000000000100000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000000000000d891ad56056d9c01f18f43f58b5c784ad07a4a49cf3d1f11623804b5cba2c6bf0000000163684415710a7d65f4ccb095edff59f897106b94d38937fc60e3ffc29892833b00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005000000003b9aca00000000010000000000000001000000090000000148ea12cb0950e47d852b99765208f5a811d3c8a47fa7b23fd524bd970019d157029f973abb91c31a146752ef8178434deb331db24c8dca5e61c961e6ac2f3b6700', - expectedComplexity: makeDimensions( - 335, - INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + - INTRINSIC_INPUT_DB_READ, - INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + - INTRINSIC_INPUT_DB_WRITE + - INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - - { - name: 'TransferSubnetOwnershipTx', - txHex: - '00000000002100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99bf1ec0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000018f6e5f2840e34f9a375f35627a44bb0b9974285d280dc3220aa9489f97b17ebd00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99ce610000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000b00000000000000000000000000000000000000020000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f010000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f01', - expectedComplexity: makeDimensions( - 436, - INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ - FeeDimensions.DBRead - ] + INTRINSIC_INPUT_DB_READ, - INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ - FeeDimensions.DBWrite - ] + - INTRINSIC_INPUT_DB_WRITE + - INTRINSIC_OUTPUT_DB_WRITE, - 0, // TODO: Implement - ), - }, - ])('$name', ({ txHex, expectedComplexity }) => { - const txBytes = hexToBuffer(txHex); - - // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); - - const tx = unpackWithManager(vm, txBytes); + test.each(TEST_TRANSACTIONS)('$name', ({ txHex, expectedComplexity }) => { + const tx = txHexToPVMTransaction(txHex); const result = txComplexity(tx); expect(result).toEqual(expectedComplexity); }); - test.each([ - { - name: 'AddDelegatorTx', - txHash: - '00000000000e000000050000000000000000000000000000000000000000000000000000000000000000000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000007000000003b9aca0000000000000000000000000100000001f887b4c7030e95d2495603ae5d8b14cc0a66781a000000011767be999a49ca24fe705de032fa613b682493110fd6468ae7fb56bde1b9d729000000003d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000005000000012a05f20000000001000000000000000400000000c51c552c49174e2e18b392049d3e4cd48b11490f000000005f692452000000005f73b05200000000ee6b2800000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000700000000ee6b280000000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb0000000b00000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb00000001000000090000000135cd78758035ed528d230317e5d880083a86a2b68c4a95655571828fe226548f235031c8dabd1fe06366a57613c4370ac26c4c59d1a1c46287a59906ec41b88f00', - }, - - { - name: 'AddValidatorTx', - txHash: - '00000000000c0000000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000f4b21e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000015000000006134088000000005000001d1a94a200000000001000000000000000400000000b3da694c70b8bee4478051313621c3f2282088b4000000005f6976d500000000614aaa19000001d1a94a20000000000121e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000016000000006134088000000007000001d1a94a20000000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c40000000b0000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c400009c40000000010000000900000001620513952dd17c8726d52e9e621618cb38f09fd194abb4cd7b4ee35ecd10880a562ad968dc81a89beab4e87d88d5d582aa73d0d265c87892d1ffff1f6e00f0ef00', - }, - - { - name: 'RewardValidatorTx', - txHash: - '0000000000143d0ad12b8ee8928edf248ca91ca55600fb383f07c32bff1d6dec472b25cf59a700000000', - }, + test.each(TEST_UNSUPPORTED_TRANSACTIONS)( + 'unsupported tx - $name', + ({ txHex }) => { + const tx = txHexToPVMTransaction(txHex); - { - name: 'AdvanceTimeTx', - txHash: '0000000000130000000066a56fe700000000', + expect(() => { + txComplexity(tx); + }).toThrow('Unsupported transaction type.'); }, - ])('unsupported tx - $name', ({ txHash }) => { - const txBytes = hexToBuffer(txHash); - - const tx = unpackWithManager(vm, txBytes); - - expect(() => { - txComplexity(tx); - }).toThrow('Unsupported transaction type.'); - }); + ); }); }); diff --git a/src/vms/pvm/txs/fee/fixtures/transactions.ts b/src/vms/pvm/txs/fee/fixtures/transactions.ts new file mode 100644 index 000000000..29b329b21 --- /dev/null +++ b/src/vms/pvm/txs/fee/fixtures/transactions.ts @@ -0,0 +1,271 @@ +import type { Dimensions } from '../../../../common/fees/dimensions'; +import { + FeeDimensions, + makeDimensions, +} from '../../../../common/fees/dimensions'; +import { + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_INPUT_DB_READ, + INTRINSIC_INPUT_DB_WRITE, + INTRINSIC_OUTPUT_DB_WRITE, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, +} from '../constants'; + +export const TEST_DYNAMIC_PRICE = 100n; + +export const TEST_DYNAMIC_WEIGHTS: Dimensions = makeDimensions( + 1, + 200, + 300, + 0, // TODO: Populate +); + +export const TEST_TRANSACTIONS: ReadonlyArray<{ + name: string; + txHex: string; + expectedComplexity: Dimensions; + expectedDynamicFee: bigint; +}> = [ + { + name: 'BaseTx', + txHex: + '00000000002200003039000000000000000000000000000000000000000000000000000000000000000000000002dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fadbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834ed587af80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001fa4ff39749d44f29563ed9da03193d4a19ef419da4ce326594817ca266fda5ed00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1131bbc00000000100000000000000000000000100000009000000014a7b54c63dd25a532b5fe5045b6d0e1db876e067422f12c9c327333c2c792d9273405ac8bbbc2cce549bbd3d0f9274242085ee257adfdb859b0f8d55bdd16fb000', + expectedComplexity: makeDimensions( + 399, + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 149_900n, + }, + + { + name: 'AddPermissionlessValidatorTx for primary network', + txHex: + '00000000001900003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba8b1e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f263d53e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae35f0000000066b692df000001d1a94a200000000000000000000000000000000000000000000000000000000000000000000000001ca3783a891cb41cadbfcf456da149f30e7af972677a162b984bef0779f254baac51ec042df1781d1295df80fb41c801269731fc6c25e1e5940dc3cb8509e30348fa712742cfdc83678acc9f95908eb98b89b28802fb559b4a2a6ff3216707c07f0ceb0b45a95f4f9a9540bbd3331d8ab4f233bffa4abb97fad9d59a1695f31b92a2b89e365facf7ab8c30de7c4a496d1e00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0007a12000000001000000090000000135f122f90bcece0d6c43e07fed1829578a23bc1734f8a4b46203f9f192ea1aec7526f3dca8fddec7418988615e6543012452bae1544275aae435313ec006ec9000', + expectedComplexity: makeDimensions( + 691, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 229_100n, + }, + + { + name: 'AddPermissionlessValidatorTx for subnet', + txHex: + '000000000019000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006091000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba6c9980000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000002038b42b73d3dc695c76ca12f966e97fe0681b1200f9a5e28d088720a18ea23c9000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609b0000000100000000a378b74b3293a9d885bd9961f2cc2e1b3364d393c9be875964f2bd614214572c00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba7bdbc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57a160000000066b7ef16000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000001b000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000000000000000000000b00000000000000000000000000000000000f4240000000020000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae6000000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae600', + expectedComplexity: makeDimensions( + 748, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 2 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 2 * INTRINSIC_INPUT_DB_WRITE + + 3 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 314_800n, + }, + + { + name: 'AddPermissionlessDelegatorTx for primary network', + txHex: + '00000000001a00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1140fe00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000017d199179744b3b82d0071c83c2fb7dd6b95a2cdbe9dde295e0ae4f8c2287370300000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba8b1e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae6080000000066ad5b08000001d1a94a2000000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000100000009000000012261556f74a29f02ffc2725a567db2c81f75d0892525dbebaa1cf8650534cc70061123533a9553184cb02d899943ff0bf0b39c77b173c133854bc7c8bc7ab9a400', + expectedComplexity: makeDimensions( + 499, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 1 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 1 * INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 209_900n, + }, + + { + name: 'AddPermissionlessDelegatorTx for subnet', + txHex: + '00000000001a000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006087000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700470c1336195b80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000029494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609100000001000000009494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500470c1336289dc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57c1d0000000066b7f11d000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c124000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b00000000000000000000000000000000000000020000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a000000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a00', + expectedComplexity: makeDimensions( + 720, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + + 2 * INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + 2 * INTRINSIC_INPUT_DB_WRITE + + 3 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 312_000n, + }, + + { + name: 'AddSubnetValidatorTx', + txHex: + '00000000000d00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1131bbc0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000138f94d1a0514eaabdaf4c52cad8d62b26cee61eaa951f5b75a5e57c2ee3793c800000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1140fe00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae7c90000000066ad5cc9000000000000c13797ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000200000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb60000000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb600', + expectedComplexity: makeDimensions( + 460, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 196_000n, + }, + + { + name: 'CreateChainTx', + txHex: + '00000000000f00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f263d53e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000197ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f269cb1f0000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400096c65742074686572657873766d00000000000000000000000000000000000000000000000000000000000000000000002a000000000000669ae21e000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cffffffffffffffff0000000a0000000100000000000000020000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f8010000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f801', + expectedComplexity: makeDimensions( + 509, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 180_900n, + }, + + { + name: 'CreateSubnetTx', + txHex: + '00000000001000003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f269cb1f00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc100000000000100000000000000000000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000010000000900000001b3c905e7227e619bd6b98c164a8b2b4a8ce89ac5142bbb1c42b139df2d17fd777c4c76eae66cef3de90800e567407945f58d918978f734f8ca4eda6923c78eb201', + expectedComplexity: makeDimensions( + 339, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 143_900n, + }, + + { + name: 'ExportTx', + txHex: + '00000000001200003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99dda340000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001f62c03574790b6a31a988f90c3e91c50fdd6f5d93baf200057463021ff23ec5c00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834ed587af800000000100000000000000009d0775f450604bd2fbc49ce0c5c1c6dfeb2dc2acb8c92c26eeae6e6df4502b1900000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fa000000010000000900000001129a07c92045e0b9d0a203fcb5b53db7890fabce1397ff6a2ad16c98ef0151891ae72949d240122abf37b1206b95e05ff171df164a98e6bdf2384432eac2c30200', + expectedComplexity: makeDimensions( + 435, + INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + 2 * INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 153_500n, + }, + + { + name: 'ImportTx', + txHex: + '00000000001100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b8b87c0000000000000000100000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000000000000d891ad56056d9c01f18f43f58b5c784ad07a4a49cf3d1f11623804b5cba2c6bf0000000163684415710a7d65f4ccb095edff59f897106b94d38937fc60e3ffc29892833b00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005000000003b9aca00000000010000000000000001000000090000000148ea12cb0950e47d852b99765208f5a811d3c8a47fa7b23fd524bd970019d157029f973abb91c31a146752ef8178434deb331db24c8dca5e61c961e6ac2f3b6700', + expectedComplexity: makeDimensions( + 335, + INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 113_500n, + }, + + { + name: 'RemoveSubnetValidatorTx', + txHex: + '00000000001700003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99ce6100000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001cd4569cfd044d50636fa597c700710403b3b52d3b75c30c542a111cc52c911ec00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99dda340000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a0000000100000000000000020000000900000001673ee3e5a3a1221935274e8ff5c45b27ebe570e9731948e393a8ebef6a15391c189a54de7d2396095492ae171103cd4bfccfc2a4dafa001d48c130694c105c2d010000000900000001673ee3e5a3a1221935274e8ff5c45b27ebe570e9731948e393a8ebef6a15391c189a54de7d2396095492ae171103cd4bfccfc2a4dafa001d48c130694c105c2d01', + expectedComplexity: makeDimensions( + 436, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + + INTRINSIC_INPUT_DB_READ, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBWrite] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 193_600n, + }, + + { + name: 'TransferSubnetOwnershipTx', + txHex: + '00000000002100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99bf1ec0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000018f6e5f2840e34f9a375f35627a44bb0b9974285d280dc3220aa9489f97b17ebd00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99ce610000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000b00000000000000000000000000000000000000020000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f010000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f01', + expectedComplexity: makeDimensions( + 436, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ + FeeDimensions.DBRead + ] + INTRINSIC_INPUT_DB_READ, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ + FeeDimensions.DBWrite + ] + + INTRINSIC_INPUT_DB_WRITE + + INTRINSIC_OUTPUT_DB_WRITE, + 0, // TODO: Implement + ), + expectedDynamicFee: 173_600n, + }, +]; + +export const TEST_UNSUPPORTED_TRANSACTIONS = [ + { + name: 'AddDelegatorTx', + txHex: + '00000000000e000000050000000000000000000000000000000000000000000000000000000000000000000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000007000000003b9aca0000000000000000000000000100000001f887b4c7030e95d2495603ae5d8b14cc0a66781a000000011767be999a49ca24fe705de032fa613b682493110fd6468ae7fb56bde1b9d729000000003d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa00000005000000012a05f20000000001000000000000000400000000c51c552c49174e2e18b392049d3e4cd48b11490f000000005f692452000000005f73b05200000000ee6b2800000000013d9bdac0ed1d761330cf680efdeb1a42159eb387d6d2950c96f7d28f61bbe2aa0000000700000000ee6b280000000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb0000000b00000000000000000000000100000001e0cfe8cae22827d032805ded484e393ce51cbedb00000001000000090000000135cd78758035ed528d230317e5d880083a86a2b68c4a95655571828fe226548f235031c8dabd1fe06366a57613c4370ac26c4c59d1a1c46287a59906ec41b88f00', + }, + + { + name: 'AddValidatorTx', + txHex: + '00000000000c0000000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000f4b21e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000015000000006134088000000005000001d1a94a200000000001000000000000000400000000b3da694c70b8bee4478051313621c3f2282088b4000000005f6976d500000000614aaa19000001d1a94a20000000000121e67317cbc4be2aeb00677ad6462778a8f52274b9d605df2591b23027a87dff00000016000000006134088000000007000001d1a94a20000000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c40000000b0000000000000000000000010000000120868ed5ac611711b33d2e4f97085347415db1c400009c40000000010000000900000001620513952dd17c8726d52e9e621618cb38f09fd194abb4cd7b4ee35ecd10880a562ad968dc81a89beab4e87d88d5d582aa73d0d265c87892d1ffff1f6e00f0ef00', + }, + + { + name: 'RewardValidatorTx', + txHex: + '0000000000143d0ad12b8ee8928edf248ca91ca55600fb383f07c32bff1d6dec472b25cf59a700000000', + }, + + { + name: 'AdvanceTimeTx', + txHex: '0000000000130000000066a56fe700000000', + }, +]; From ff1366ce13eb50ce0238ced41877bdaa287b1264 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Tue, 20 Aug 2024 10:32:14 -0600 Subject: [PATCH 09/39] chore: add cspell config and vscode extension recommendation --- .vscode/extensions.json | 3 +++ cspell.json | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 cspell.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..ff1b97a2e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["streetsidesoftware.code-spell-checker"] +} diff --git a/cspell.json b/cspell.json new file mode 100644 index 000000000..9dfe4f329 --- /dev/null +++ b/cspell.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "dictionaries": [ + "companies", + "css", + "en_us", + "en-gb", + "fullstack", + "html", + "lorem-ipsum", + "node", + "npm", + "softwareTerms", + "sql", + "typescript" + ], + "ignorePaths": ["node_modules", "__generated__", "build", "dist", "out"], + "ignoreRegExpList": ["/.*[0-9].*/"], + "language": "en", + "minWordLength": 5, + "words": ["avalabs", "locktime", "stakeable", "unstakeable", "utxo", "utxos"] +} From 03bb77a14babb9d660e0268dccba98b8e099fb9e Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Tue, 20 Aug 2024 11:37:21 -0600 Subject: [PATCH 10/39] feat: add pvm builder complexity logic --- src/vms/common/fees/dimensions.ts | 4 +- src/vms/pvm/builder.ts | 233 +++++++++++++++++- src/vms/pvm/txs/fee/calculator.ts | 4 +- src/vms/pvm/txs/fee/complexity.test.ts | 100 ++++---- src/vms/pvm/txs/fee/complexity.ts | 86 +++---- src/vms/pvm/txs/fee/fixtures/transactions.ts | 28 +-- src/vms/pvm/txs/fee/index.ts | 22 ++ .../utils/calculateSpend/calculateSpend.ts | 48 ++-- src/vms/utils/calculateSpend/models.ts | 2 + .../utils/verifySignaturesMatch.ts | 4 +- 10 files changed, 390 insertions(+), 141 deletions(-) create mode 100644 src/vms/pvm/txs/fee/index.ts diff --git a/src/vms/common/fees/dimensions.ts b/src/vms/common/fees/dimensions.ts index c4fb54427..670d711af 100644 --- a/src/vms/common/fees/dimensions.ts +++ b/src/vms/common/fees/dimensions.ts @@ -9,14 +9,14 @@ type DimensionValue = number; export type Dimensions = Record; -export const getEmptyDimensions = (): Dimensions => ({ +export const createEmptyDimensions = (): Dimensions => ({ [FeeDimensions.Bandwidth]: 0, [FeeDimensions.DBRead]: 0, [FeeDimensions.DBWrite]: 0, [FeeDimensions.Compute]: 0, }); -export const makeDimensions = ( +export const createDimensions = ( bandwidth: DimensionValue, dbRead: DimensionValue, dbWrite: DimensionValue, diff --git a/src/vms/pvm/builder.ts b/src/vms/pvm/builder.ts index eb6b42610..8eaaca253 100644 --- a/src/vms/pvm/builder.ts +++ b/src/vms/pvm/builder.ts @@ -43,11 +43,41 @@ import { import { NodeId } from '../../serializable/fxs/common/nodeId'; import { createSignerOrSignerEmptyFromStrings } from '../../serializable/pvm/signer'; import { baseTxUnsafePvm } from '../common'; +import type { Dimensions } from '../common/fees/dimensions'; +import { + addDimensions, + createDimensions, + createEmptyDimensions, +} from '../common/fees/dimensions'; +import { + ID_LEN, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + getAuthComplexity, + getInputComplexity, + getOutputComplexity, + getOwnerComplexity, + getSignerComplexity, +} from './txs/fee'; /* Builder is useful for building transactions that are specific to a chain. */ +const getMemoComplexity = ( + spendOptions: Required, +): Dimensions => { + return createDimensions(spendOptions.memo.length, 0, 0, 0); +}; + /** * @param fromAddresses - used for selecting which utxos are signable * @param utxoSet - list of utxos to spend from @@ -74,6 +104,16 @@ export function newBaseTx( toBurn.set(assetId, (toBurn.get(assetId) || 0n) + out.output.amount()); }); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_BASE_TX_COMPLEXITIES, + memoComplexity, + outputComplexity, + ); + const { inputs, inputUTXOs, changeOutputs, addressMaps } = calculateUTXOSpend( toBurn, undefined, @@ -81,6 +121,7 @@ export function newBaseTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs, useConsolidateOutputs], + complexity, ); const allOutputs = [...outputs, ...changeOutputs]; @@ -143,6 +184,38 @@ export function newImportTx( if (!importedInputs.length) { throw new Error('no UTXOs available to import'); } + + const outputs: TransferableOutput[] = []; + + for (const [assetID, amount] of Object.entries(importedAmounts)) { + if (assetID === context.avaxAssetID) { + continue; + } + + outputs.push( + TransferableOutput.fromNative( + assetID, + amount, + toAddresses, + locktime, + threshold, + ), + ); + } + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const inputComplexity = getInputComplexity(importedInputs); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_IMPORT_TX_COMPLEXITIES, + memoComplexity, + inputComplexity, + outputComplexity, + ); + let inputs: TransferableInput[] = []; let changeOutputs: TransferableOutput[] = []; @@ -158,6 +231,7 @@ export function newImportTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs], + complexity, ); inputs = spendRes.inputs; changeOutputs = spendRes.changeOutputs; @@ -216,7 +290,7 @@ const getToBurn = ( * @param start The Unix time based on p-chain timestamp when the validator starts validating the Primary Network. * @param end The Unix time based on p-chain timestamp when the validator stops validating the Primary Network (and staked AVAX is returned). * @param weight The amount being delegated in nAVAX - * @param rewardAddresses The addresses which will recieve the rewards from the delegated stake. + * @param rewardAddresses The addresses which will receive the rewards from the delegated stake. * @param shares A number for the percentage times 10,000 of reward to be given to the validator when someone delegates to them. * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs @@ -251,6 +325,7 @@ export function newAddValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], + createEmptyDimensions(), ); const validatorTx = new AddValidatorTx( @@ -276,7 +351,7 @@ export function newAddValidatorTx( * @param utxos list of utxos to choose from * @param outputs list of outputs to create. * @param options used for filtering UTXO's - * @returns unsingedTx containing an exportTx + * @returns unsignedTx containing an exportTx */ export function newExportTx( @@ -292,6 +367,16 @@ export function newExportTx( const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); const toBurn = getToBurn(context, outputs, context.baseTxFee); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_EXPORT_TX_COMPLEXITIES, + memoComplexity, + outputComplexity, + ); + const { inputs, changeOutputs, addressMaps, inputUTXOs } = calculateUTXOSpend( toBurn, undefined, @@ -299,6 +384,7 @@ export function newExportTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs], + complexity, ); outputs.sort(compareTransferableOutputs); @@ -328,11 +414,11 @@ export function newExportTx( * @param start The Unix time based on p-chain timestamp when the validator starts validating the Primary Network. * @param end The Unix time based on p-chain timestamp when the validator stops validating the Primary Network (and staked AVAX is returned). * @param weight The amount being delegated in nAVAX - * @param rewardAddresses The addresses which will recieve the rewards from the delegated stake. + * @param rewardAddresses The addresses which will receive the rewards from the delegated stake. * @param options - used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns UnsingedTx + * @returns unsignedTx */ export function newAddDelegatorTx( @@ -362,6 +448,7 @@ export function newAddDelegatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], + createEmptyDimensions(), ); const addDelegatorTx = new AddDelegatorTx( @@ -385,11 +472,11 @@ export function newAddDelegatorTx( * @param context * @param utxos list of utxos to choose from * @param fromAddressesBytes used for filtering utxos - * @param rewardAddresses The addresses which will recieve the rewards from the delegated stake. + * @param rewardAddresses The addresses which will receive the rewards from the delegated stake. * @param options used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns UnsingedTx + * @returns unsignedTx */ export function newCreateSubnetTx( context: Context, @@ -402,6 +489,18 @@ export function newCreateSubnetTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const ownerComplexity = getOwnerComplexity( + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ); + + const complexity = addDimensions( + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + memoComplexity, + ownerComplexity, + ); + const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.createSubnetTxFee]]), undefined, @@ -409,6 +508,7 @@ export function newCreateSubnetTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + complexity, ); const createSubnetTx = new CreateSubnetTx( @@ -454,6 +554,28 @@ export function newCreateBlockchainTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const genesisBytes = new Bytes( + new TextEncoder().encode(JSON.stringify(genesisData)), + ); + + const dynamicComplexity = createDimensions( + fxIds.length * ID_LEN + + chainName.length + + genesisBytes.length + + defaultedOptions.memo.length, + 0, + 0, + 0, + ); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const complexity = addDimensions( + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + dynamicComplexity, + authComplexity, + ); + const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), undefined, @@ -461,6 +583,7 @@ export function newCreateBlockchainTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + complexity, ); const createChainTx = new CreateChainTx( @@ -475,7 +598,7 @@ export function newCreateBlockchainTx( new Stringpr(chainName), Id.fromString(vmID), fxIds.map(Id.fromString.bind(Id)), - new Bytes(new TextEncoder().encode(JSON.stringify(genesisData))), + genesisBytes, Input.fromNative(subnetAuth), ); @@ -496,6 +619,16 @@ export function newAddSubnetValidatorTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const complexity = addDimensions( + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ); + const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), undefined, @@ -503,6 +636,7 @@ export function newAddSubnetValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + complexity, ); const addSubnetValidatorTx = new AddSubnetValidatorTx( @@ -536,6 +670,16 @@ export function newRemoveSubnetValidatorTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const complexity = addDimensions( + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ); + const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.baseTxFee]]), undefined, @@ -543,6 +687,7 @@ export function newRemoveSubnetValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + complexity, ); const removeSubnetValidatorTx = new RemoveSubnetValidatorTx( @@ -616,6 +761,32 @@ export function newAddPermissionlessValidatorTx( const toStake = new Map([[assetId, weight]]); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const signer = createSignerOrSignerEmptyFromStrings(publicKey, signature); + const validatorOutputOwners = OutputOwners.fromNative( + rewardAddresses, + locktime, + threshold, + ); + const delegatorOutputOwners = OutputOwners.fromNative( + delegatorRewardsOwner, + 0n, + ); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const signerComplexity = getSignerComplexity(signer); + const validatorOwnerComplexity = getOwnerComplexity(validatorOutputOwners); + const delegatorOwnerComplexity = getOwnerComplexity(delegatorOutputOwners); + + const complexity = addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + signerComplexity, + validatorOwnerComplexity, + delegatorOwnerComplexity, + ); + const { addressMaps, changeOutputs, inputUTXOs, inputs, stakeOutputs } = calculateUTXOSpend( toBurn, @@ -624,6 +795,7 @@ export function newAddPermissionlessValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], + complexity, ); const validatorTx = new AddPermissionlessValidatorTx( @@ -641,10 +813,10 @@ export function newAddPermissionlessValidatorTx( weight, Id.fromString(subnetID), ), - createSignerOrSignerEmptyFromStrings(publicKey, signature), + signer, stakeOutputs, - OutputOwners.fromNative(rewardAddresses, locktime, threshold), - OutputOwners.fromNative(delegatorRewardsOwner, 0n), + validatorOutputOwners, + delegatorOutputOwners, new Int(shares), ); return new UnsignedTx(validatorTx, inputUTXOs, addressMaps); @@ -700,6 +872,23 @@ export function newAddPermissionlessDelegatorTx( const toStake = new Map([[assetId, weight]]); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const delegatorRewardsOwner = OutputOwners.fromNative( + rewardAddresses, + locktime, + threshold, + ); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const ownerComplexity = getOwnerComplexity(delegatorRewardsOwner); + + const complexity = addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + memoComplexity, + ownerComplexity, + ); + const { addressMaps, changeOutputs, inputUTXOs, inputs, stakeOutputs } = calculateUTXOSpend( toBurn, @@ -708,6 +897,7 @@ export function newAddPermissionlessDelegatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], + complexity, ); const delegatorTx = new AddPermissionlessDelegatorTx( @@ -726,7 +916,7 @@ export function newAddPermissionlessDelegatorTx( Id.fromString(subnetID), ), stakeOutputs, - OutputOwners.fromNative(rewardAddresses, locktime, threshold), + delegatorRewardsOwner, ); return new UnsignedTx(delegatorTx, inputUTXOs, addressMaps); } @@ -751,7 +941,7 @@ export function newAddPermissionlessDelegatorTx( * @param uptimeRequirement the minimum percentage a validator must be online and responsive to receive a reward * @param subnetAuth specifies indices of existing subnet owners * @param options used for filtering utxos - * @returns UnsingedTx containing a TransformSubnetTx + * @returns unsignedTx containing a TransformSubnetTx */ export function newTransformSubnetTx( context: Context, @@ -783,6 +973,7 @@ export function newTransformSubnetTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + createEmptyDimensions(), ); return new UnsignedTx( @@ -825,7 +1016,7 @@ export function newTransformSubnetTx( * @param options used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns UnsingedTx containing a TransferSubnetOwnershipTx + * @returns unsignedTx containing a TransferSubnetOwnershipTx */ export function newTransferSubnetOwnershipTx( context: Context, @@ -840,6 +1031,21 @@ export function newTransferSubnetOwnershipTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const ownerComplexity = getOwnerComplexity( + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ); + + const complexity = addDimensions( + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ownerComplexity, + ); + const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.baseTxFee]]), undefined, @@ -847,6 +1053,7 @@ export function newTransferSubnetOwnershipTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], + complexity, ); return new UnsignedTx( diff --git a/src/vms/pvm/txs/fee/calculator.ts b/src/vms/pvm/txs/fee/calculator.ts index fd7038839..c897f0b33 100644 --- a/src/vms/pvm/txs/fee/calculator.ts +++ b/src/vms/pvm/txs/fee/calculator.ts @@ -3,7 +3,7 @@ import { dimensionsToGas, type Dimensions, } from '../../../common/fees/dimensions'; -import { txComplexity } from './complexity'; +import { getTxComplexity } from './complexity'; /** * Calculates the minimum required fee, in nAVAX, that an unsigned @@ -16,7 +16,7 @@ export const calculateFee = ( weights: Dimensions, price: bigint, ): bigint => { - const complexity = txComplexity(tx); + const complexity = getTxComplexity(tx); const gas = dimensionsToGas(complexity, weights); diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index f6e5f0394..c1a140367 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -16,14 +16,14 @@ import { StakeableLockOut, } from '../../../../serializable/pvm'; import { hexToBuffer, unpackWithManager } from '../../../../utils'; -import { makeDimensions } from '../../../common/fees/dimensions'; +import { createDimensions } from '../../../common/fees/dimensions'; import { - authComplexity, - inputComplexity, - outputComplexity, - ownerComplexity, - signerComplexity, - txComplexity, + getAuthComplexity, + getInputComplexity, + getOutputComplexity, + getOwnerComplexity, + getSignerComplexity, + getTxComplexity, } from './complexity'; import { TEST_TRANSACTIONS, @@ -62,33 +62,33 @@ const txHexToPVMTransaction = (txHex: string) => { }; describe('Complexity', () => { - describe('outputComplexity', () => { + describe('getOutputComplexity', () => { test('empty transferable output', () => { - const result = outputComplexity([]); + const result = getOutputComplexity([]); - expect(result).toEqual(makeDimensions(0, 0, 0, 0)); + expect(result).toEqual(createDimensions(0, 0, 0, 0)); }); test('any can spend', () => { - const result = outputComplexity([makeTransferableOutput()]); + const result = getOutputComplexity([makeTransferableOutput()]); - expect(result).toEqual(makeDimensions(60, 0, 1, 0)); + expect(result).toEqual(createDimensions(60, 0, 1, 0)); }); test('one owner', () => { - const result = outputComplexity([makeTransferableOutput(1)]); + const result = getOutputComplexity([makeTransferableOutput(1)]); - expect(result).toEqual(makeDimensions(80, 0, 1, 0)); + expect(result).toEqual(createDimensions(80, 0, 1, 0)); }); test('three owners', () => { - const result = outputComplexity([makeTransferableOutput(3)]); + const result = getOutputComplexity([makeTransferableOutput(3)]); - expect(result).toEqual(makeDimensions(120, 0, 1, 0)); + expect(result).toEqual(createDimensions(120, 0, 1, 0)); }); test('locked stakeable', () => { - const result = outputComplexity([ + const result = getOutputComplexity([ new TransferableOutput( id(), new StakeableLockOut( @@ -98,16 +98,16 @@ describe('Complexity', () => { ), ]); - expect(result).toEqual(makeDimensions(132, 0, 1, 0)); + expect(result).toEqual(createDimensions(132, 0, 1, 0)); }); }); - describe('inputComplexity', () => { + describe('getInputComplexity', () => { test('any can spend', () => { - const result = inputComplexity([makeTransferableInput()]); + const result = getInputComplexity([makeTransferableInput()]); expect(result).toEqual( - makeDimensions( + createDimensions( 92, 1, 1, @@ -117,10 +117,10 @@ describe('Complexity', () => { }); test('one owner', () => { - const result = inputComplexity([makeTransferableInput(1)]); + const result = getInputComplexity([makeTransferableInput(1)]); expect(result).toEqual( - makeDimensions( + createDimensions( 161, 1, 1, @@ -130,10 +130,10 @@ describe('Complexity', () => { }); test('three owners', () => { - const result = inputComplexity([makeTransferableInput(3)]); + const result = getInputComplexity([makeTransferableInput(3)]); expect(result).toEqual( - makeDimensions( + createDimensions( 299, 1, 1, @@ -143,7 +143,7 @@ describe('Complexity', () => { }); test('locked stakeable', () => { - const result = inputComplexity([ + const result = getInputComplexity([ new TransferableInput( utxoId(), id(), @@ -155,7 +155,7 @@ describe('Complexity', () => { ]); expect(result).toEqual( - makeDimensions( + createDimensions( 311, 1, 1, @@ -165,38 +165,38 @@ describe('Complexity', () => { }); }); - describe('ownerComplexity', () => { + describe('getOwnerComplexity', () => { test('any can spend', () => { - const result = ownerComplexity(makeOutputOwners()); + const result = getOwnerComplexity(makeOutputOwners()); - expect(result).toEqual(makeDimensions(16, 0, 0, 0)); + expect(result).toEqual(createDimensions(16, 0, 0, 0)); }); test('one owner', () => { - const result = ownerComplexity(makeOutputOwners(1)); + const result = getOwnerComplexity(makeOutputOwners(1)); - expect(result).toEqual(makeDimensions(36, 0, 0, 0)); + expect(result).toEqual(createDimensions(36, 0, 0, 0)); }); test('three owners', () => { - const result = ownerComplexity(makeOutputOwners(3)); + const result = getOwnerComplexity(makeOutputOwners(3)); - expect(result).toEqual(makeDimensions(76, 0, 0, 0)); + expect(result).toEqual(createDimensions(76, 0, 0, 0)); }); }); - describe('signerComplexity', () => { + describe('getSignerComplexity', () => { test('empty signer', () => { - const result = signerComplexity(new SignerEmpty()); + const result = getSignerComplexity(new SignerEmpty()); - expect(result).toEqual(makeDimensions(0, 0, 0, 0)); + expect(result).toEqual(createDimensions(0, 0, 0, 0)); }); test('bls pop', () => { - const result = signerComplexity(signer()); + const result = getSignerComplexity(signer()); expect(result).toEqual( - makeDimensions( + createDimensions( 144, 0, 0, @@ -207,12 +207,12 @@ describe('Complexity', () => { }); }); - describe('authComplexity', () => { + describe('getAuthComplexity', () => { test('any can spend', () => { - const result = authComplexity(new Input([])); + const result = getAuthComplexity(new Input([])); expect(result).toEqual( - makeDimensions( + createDimensions( 8, 0, 0, @@ -222,10 +222,10 @@ describe('Complexity', () => { }); test('one owner', () => { - const result = authComplexity(new Input([int()])); + const result = getAuthComplexity(new Input([int()])); expect(result).toEqual( - makeDimensions( + createDimensions( 77, 0, 0, @@ -235,10 +235,10 @@ describe('Complexity', () => { }); test('three owners', () => { - const result = authComplexity(new Input(ints())); + const result = getAuthComplexity(new Input(ints())); expect(result).toEqual( - makeDimensions( + createDimensions( 215, 0, 0, @@ -249,18 +249,18 @@ describe('Complexity', () => { test('invalid auth type', () => { expect(() => { - authComplexity(int()); + getAuthComplexity(int()); }).toThrow( 'Unable to calculate auth complexity of transaction. Expected Input as subnet auth.', ); }); }); - describe('txComplexity', () => { + describe('getTxComplexity', () => { test.each(TEST_TRANSACTIONS)('$name', ({ txHex, expectedComplexity }) => { const tx = txHexToPVMTransaction(txHex); - const result = txComplexity(tx); + const result = getTxComplexity(tx); expect(result).toEqual(expectedComplexity); }); @@ -271,7 +271,7 @@ describe('Complexity', () => { const tx = txHexToPVMTransaction(txHex); expect(() => { - txComplexity(tx); + getTxComplexity(tx); }).toThrow('Unsupported transaction type.'); }, ); diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 68b3a6c03..e3f4b124b 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -40,8 +40,8 @@ import type { Dimensions } from '../../../common/fees/dimensions'; import { FeeDimensions, addDimensions, - getEmptyDimensions, - makeDimensions, + createEmptyDimensions, + createDimensions, } from '../../../common/fees/dimensions'; import type { Serializable } from '../../../common/types'; import type { Transaction } from '../../../common'; @@ -76,10 +76,10 @@ import { /** * Returns the complexity outputs add to a transaction. */ -export const outputComplexity = ( +export const getOutputComplexity = ( transferableOutputs: TransferableOutput[], ): Dimensions => { - let complexity = getEmptyDimensions(); + let complexity = createEmptyDimensions(); for (const transferableOutput of transferableOutputs) { // outputComplexity logic @@ -118,10 +118,10 @@ export const outputComplexity = ( * * It includes the complexity that the corresponding credentials will add. */ -export const inputComplexity = ( +export const getInputComplexity = ( transferableInputs: TransferableInput[], ): Dimensions => { - let complexity = getEmptyDimensions(); + let complexity = createEmptyDimensions(); for (const transferableInput of transferableInputs) { const inputComplexity: Dimensions = { @@ -152,12 +152,14 @@ export const inputComplexity = ( return complexity; }; -export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { +export const getSignerComplexity = ( + signer: Signer | SignerEmpty, +): Dimensions => { if (signer instanceof SignerEmpty) { - return getEmptyDimensions(); + return createEmptyDimensions(); } - return makeDimensions( + return createDimensions( INTRINSIC_POP_BANDWIDTH, 0, 0, @@ -165,14 +167,14 @@ export const signerComplexity = (signer: Signer | SignerEmpty): Dimensions => { ); }; -export const ownerComplexity = (outputOwners: OutputOwners): Dimensions => { +export const getOwnerComplexity = (outputOwners: OutputOwners): Dimensions => { const numberOfAddresses = outputOwners.addrs.length; const addressBandwidth = numberOfAddresses * SHORT_ID_LEN; const bandwidth = addressBandwidth + INTRINSIC_SECP256K1_FX_OUTPUT_OWNERS_BANDWIDTH; - return makeDimensions(bandwidth, 0, 0, 0); + return createDimensions(bandwidth, 0, 0, 0); }; /** @@ -181,7 +183,7 @@ export const ownerComplexity = (outputOwners: OutputOwners): Dimensions => { * It does include the complexity that the corresponding credential will add. * It does not include the typeID of the credential. */ -export const authComplexity = (input: Serializable): Dimensions => { +export const getAuthComplexity = (input: Serializable): Dimensions => { // TODO: Not a fan of this. May be better to re-type `subnetAuth` as `Input` in `AddSubnetValidatorTx`? if (!(input instanceof Input)) { throw new Error( @@ -196,7 +198,7 @@ export const authComplexity = (input: Serializable): Dimensions => { const bandwidth = signatureBandwidth + INTRINSIC_SECP256K1_FX_INPUT_BANDWIDTH; - return makeDimensions( + return createDimensions( bandwidth, 0, 0, @@ -204,9 +206,9 @@ export const authComplexity = (input: Serializable): Dimensions => { ); }; -const baseTxComplexity = (baseTx: BaseTx): Dimensions => { - const outputsComplexity = outputComplexity(baseTx.outputs); - const inputsComplexity = inputComplexity(baseTx.inputs); +const getBaseTxComplexity = (baseTx: BaseTx): Dimensions => { + const outputsComplexity = getOutputComplexity(baseTx.outputs); + const inputsComplexity = getInputComplexity(baseTx.inputs); const complexity = addDimensions(outputsComplexity, inputsComplexity); @@ -220,11 +222,11 @@ const addPermissionlessValidatorTx = ( ): Dimensions => { return addDimensions( INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - signerComplexity(tx.signer), - outputComplexity(tx.stake), - ownerComplexity(tx.getValidatorRewardsOwner()), - ownerComplexity(tx.getDelegatorRewardsOwner()), + getBaseTxComplexity(tx.baseTx), + getSignerComplexity(tx.signer), + getOutputComplexity(tx.stake), + getOwnerComplexity(tx.getValidatorRewardsOwner()), + getOwnerComplexity(tx.getDelegatorRewardsOwner()), ); }; @@ -233,24 +235,24 @@ const addPermissionlessDelegatorTx = ( ): Dimensions => { return addDimensions( INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - ownerComplexity(tx.getDelegatorRewardsOwner()), - outputComplexity(tx.stake), + getBaseTxComplexity(tx.baseTx), + getOwnerComplexity(tx.getDelegatorRewardsOwner()), + getOutputComplexity(tx.stake), ); }; const addSubnetValidatorTx = (tx: AddSubnetValidatorTx): Dimensions => { return addDimensions( INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - authComplexity(tx.subnetAuth), + getBaseTxComplexity(tx.baseTx), + getAuthComplexity(tx.subnetAuth), ); }; const baseTx = (tx: PvmBaseTx): Dimensions => { return addDimensions( INTRINSIC_BASE_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), + getBaseTxComplexity(tx.baseTx), ); }; @@ -259,45 +261,45 @@ const createChainTx = (tx: CreateChainTx): Dimensions => { bandwidth += tx.chainName.value().length; bandwidth += tx.genesisData.length; - const dynamicComplexity = makeDimensions(bandwidth, 0, 0, 0); + const dynamicComplexity = createDimensions(bandwidth, 0, 0, 0); return addDimensions( INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, dynamicComplexity, - baseTxComplexity(tx.baseTx), - authComplexity(tx.subnetAuth), + getBaseTxComplexity(tx.baseTx), + getAuthComplexity(tx.subnetAuth), ); }; const createSubnetTx = (tx: CreateSubnetTx): Dimensions => { return addDimensions( INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - ownerComplexity(tx.getSubnetOwners()), + getBaseTxComplexity(tx.baseTx), + getOwnerComplexity(tx.getSubnetOwners()), ); }; const exportTx = (tx: ExportTx): Dimensions => { return addDimensions( INTRINSIC_EXPORT_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - outputComplexity(tx.outs), + getBaseTxComplexity(tx.baseTx), + getOutputComplexity(tx.outs), ); }; const importTx = (tx: ImportTx): Dimensions => { return addDimensions( INTRINSIC_IMPORT_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - inputComplexity(tx.ins), + getBaseTxComplexity(tx.baseTx), + getInputComplexity(tx.ins), ); }; const removeSubnetValidatorTx = (tx: RemoveSubnetValidatorTx): Dimensions => { return addDimensions( INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - authComplexity(tx.subnetAuth), + getBaseTxComplexity(tx.baseTx), + getAuthComplexity(tx.subnetAuth), ); }; @@ -306,13 +308,13 @@ const transferSubnetOwnershipTx = ( ): Dimensions => { return addDimensions( INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, - baseTxComplexity(tx.baseTx), - authComplexity(tx.subnetAuth), - ownerComplexity(tx.getSubnetOwners()), + getBaseTxComplexity(tx.baseTx), + getAuthComplexity(tx.subnetAuth), + getOwnerComplexity(tx.getSubnetOwners()), ); }; -export const txComplexity = (tx: Transaction): Dimensions => { +export const getTxComplexity = (tx: Transaction): Dimensions => { if (isAddPermissionlessValidatorTx(tx)) { return addPermissionlessValidatorTx(tx); } else if (isAddPermissionlessDelegatorTx(tx)) { diff --git a/src/vms/pvm/txs/fee/fixtures/transactions.ts b/src/vms/pvm/txs/fee/fixtures/transactions.ts index 29b329b21..c049a9744 100644 --- a/src/vms/pvm/txs/fee/fixtures/transactions.ts +++ b/src/vms/pvm/txs/fee/fixtures/transactions.ts @@ -1,7 +1,7 @@ import type { Dimensions } from '../../../../common/fees/dimensions'; import { FeeDimensions, - makeDimensions, + createDimensions, } from '../../../../common/fees/dimensions'; import { INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, @@ -21,7 +21,7 @@ import { export const TEST_DYNAMIC_PRICE = 100n; -export const TEST_DYNAMIC_WEIGHTS: Dimensions = makeDimensions( +export const TEST_DYNAMIC_WEIGHTS: Dimensions = createDimensions( 1, 200, 300, @@ -38,7 +38,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'BaseTx', txHex: '00000000002200003039000000000000000000000000000000000000000000000000000000000000000000000002dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fadbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834ed587af80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001fa4ff39749d44f29563ed9da03193d4a19ef419da4ce326594817ca266fda5ed00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1131bbc00000000100000000000000000000000100000009000000014a7b54c63dd25a532b5fe5045b6d0e1db876e067422f12c9c327333c2c792d9273405ac8bbbc2cce549bbd3d0f9274242085ee257adfdb859b0f8d55bdd16fb000', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 399, INTRINSIC_BASE_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -54,7 +54,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'AddPermissionlessValidatorTx for primary network', txHex: '00000000001900003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba8b1e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f263d53e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae35f0000000066b692df000001d1a94a200000000000000000000000000000000000000000000000000000000000000000000000001ca3783a891cb41cadbfcf456da149f30e7af972677a162b984bef0779f254baac51ec042df1781d1295df80fb41c801269731fc6c25e1e5940dc3cb8509e30348fa712742cfdc83678acc9f95908eb98b89b28802fb559b4a2a6ff3216707c07f0ceb0b45a95f4f9a9540bbd3331d8ab4f233bffa4abb97fad9d59a1695f31b92a2b89e365facf7ab8c30de7c4a496d1e00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0007a12000000001000000090000000135f122f90bcece0d6c43e07fed1829578a23bc1734f8a4b46203f9f192ea1aec7526f3dca8fddec7418988615e6543012452bae1544275aae435313ec006ec9000', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 691, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ FeeDimensions.DBRead @@ -73,7 +73,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'AddPermissionlessValidatorTx for subnet', txHex: '000000000019000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006091000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520ba6c9980000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000002038b42b73d3dc695c76ca12f966e97fe0681b1200f9a5e28d088720a18ea23c9000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609b0000000100000000a378b74b3293a9d885bd9961f2cc2e1b3364d393c9be875964f2bd614214572c00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba7bdbc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57a160000000066b7ef16000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000001b000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000000000000000000000b00000000000000000000000000000000000f4240000000020000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae6000000000900000001593fc20f88a8ce0b3470b0bb103e5f7e09f65023b6515d36660da53f9a15dedc1037ee27a8c4a27c24e20ad3b0ab4bd1ff3a02a6fcc2cbe04282bfe9902c9ae600', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 748, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES[ FeeDimensions.DBRead @@ -93,7 +93,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'AddPermissionlessDelegatorTx for primary network', txHex: '00000000001a00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1140fe00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000017d199179744b3b82d0071c83c2fb7dd6b95a2cdbe9dde295e0ae4f8c2287370300000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500238520ba8b1e00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae6080000000066ad5b08000001d1a94a2000000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000100000009000000012261556f74a29f02ffc2725a567db2c81f75d0892525dbebaa1cf8650534cc70061123533a9553184cb02d899943ff0bf0b39c77b173c133854bc7c8bc7ab9a400', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 499, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ FeeDimensions.DBRead @@ -113,7 +113,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'AddPermissionlessDelegatorTx for subnet', txHex: '00000000001a000030390000000000000000000000000000000000000000000000000000000000000000000000022f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a000000070000000000006087000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cdbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700470c1336195b80000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000029494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f000000002f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000005000000000000609100000001000000009494c80361884942e4292c3531e8e790fcf7561e74404ded27eab8634e3fb30f00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000500470c1336289dc0000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000000066a57c1d0000000066b7f11d000000000000000a97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c124000000012f6399f3e626fe1e75f9daa5e726cb64b7bfec0b6e6d8930eaa9dfa336edca7a00000007000000000000000a000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000b00000000000000000000000000000000000000020000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a000000000900000001764190e2405fef72fce0d355e3dcc58a9f5621e583ae718cb2c23b55957995d1206d0b5efcc3cef99815e17a4b2cccd700147a759b7279a131745b237659666a00', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 720, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES[ FeeDimensions.DBRead @@ -133,7 +133,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'AddSubnetValidatorTx', txHex: '00000000000d00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834f1131bbc0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000138f94d1a0514eaabdaf4c52cad8d62b26cee61eaa951f5b75a5e57c2ee3793c800000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834f1140fe00000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa00000000669ae7c90000000066ad5cc9000000000000c13797ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000200000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb60000000009000000012127130d37877fb1ec4b2374ef72571d49cd7b0319a3769e5da19041a138166c10b1a5c07cf5ccf0419066cbe3bab9827cf29f9fa6213ebdadf19d4849501eb600', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 460, INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -149,7 +149,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'CreateChainTx', txHex: '00000000000f00003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f263d53e00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000197ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f269cb1f0000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c12400096c65742074686572657873766d00000000000000000000000000000000000000000000000000000000000000000000002a000000000000669ae21e000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29cffffffffffffffff0000000a0000000100000000000000020000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f8010000000900000001cf8104877b1a59b472f4f34d360c0e4f38e92c5fa334215430d0b99cf78eae8f621b6daf0b0f5c3a58a9497601f978698a1e5545d1873db8f2f38ecb7496c2f801', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 509, INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -165,7 +165,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'CreateSubnetTx', txHex: '00000000001000003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f269cb1f00000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc100000000000100000000000000000000000b000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000010000000900000001b3c905e7227e619bd6b98c164a8b2b4a8ce89ac5142bbb1c42b139df2d17fd777c4c76eae66cef3de90800e567407945f58d918978f734f8ca4eda6923c78eb201', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 339, INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -181,7 +181,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'ExportTx', txHex: '00000000001200003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99dda340000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001f62c03574790b6a31a988f90c3e91c50fdd6f5d93baf200057463021ff23ec5c00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834ed587af800000000100000000000000009d0775f450604bd2fbc49ce0c5c1c6dfeb2dc2acb8c92c26eeae6e6df4502b1900000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b9aca00000000000000000100000002000000024a177205df5c29929d06db9d941f83d5ea985de3e902a9a86640bfdb1cd0e36c0cc982b83e5765fa000000010000000900000001129a07c92045e0b9d0a203fcb5b53db7890fabce1397ff6a2ad16c98ef0151891ae72949d240122abf37b1206b95e05ff171df164a98e6bdf2384432eac2c30200', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 435, INTRINSIC_EXPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -197,7 +197,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'ImportTx', txHex: '00000000001100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000000003b8b87c0000000000000000100000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c0000000000000000d891ad56056d9c01f18f43f58b5c784ad07a4a49cf3d1f11623804b5cba2c6bf0000000163684415710a7d65f4ccb095edff59f897106b94d38937fc60e3ffc29892833b00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005000000003b9aca00000000010000000000000001000000090000000148ea12cb0950e47d852b99765208f5a811d3c8a47fa7b23fd524bd970019d157029f973abb91c31a146752ef8178434deb331db24c8dca5e61c961e6ac2f3b6700', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 335, INTRINSIC_IMPORT_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -213,7 +213,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'RemoveSubnetValidatorTx', txHex: '00000000001700003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99ce6100000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001cd4569cfd044d50636fa597c700710403b3b52d3b75c30c542a111cc52c911ec00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99dda340000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa97ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a0000000100000000000000020000000900000001673ee3e5a3a1221935274e8ff5c45b27ebe570e9731948e393a8ebef6a15391c189a54de7d2396095492ae171103cd4bfccfc2a4dafa001d48c130694c105c2d010000000900000001673ee3e5a3a1221935274e8ff5c45b27ebe570e9731948e393a8ebef6a15391c189a54de7d2396095492ae171103cd4bfccfc2a4dafa001d48c130694c105c2d01', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 436, INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES[FeeDimensions.DBRead] + INTRINSIC_INPUT_DB_READ, @@ -229,7 +229,7 @@ export const TEST_TRANSACTIONS: ReadonlyArray<{ name: 'TransferSubnetOwnershipTx', txHex: '00000000002100003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000070023834e99bf1ec0000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c000000018f6e5f2840e34f9a375f35627a44bb0b9974285d280dc3220aa9489f97b17ebd00000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db000000050023834e99ce610000000001000000000000000097ea88082100491617204ed70c19fc1a2fce4474bee962904359d0b59e84c1240000000a00000001000000000000000b00000000000000000000000000000000000000020000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f010000000900000001e3479034ed8134dd23e154e1ec6e61b25073a20750ebf808e50ec1aae180ef430f8151347afdf6606bc7866f7f068b01719e4dad12e2976af1159fb048f73f7f01', - expectedComplexity: makeDimensions( + expectedComplexity: createDimensions( 436, INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES[ FeeDimensions.DBRead diff --git a/src/vms/pvm/txs/fee/index.ts b/src/vms/pvm/txs/fee/index.ts new file mode 100644 index 000000000..5ee1daa0d --- /dev/null +++ b/src/vms/pvm/txs/fee/index.ts @@ -0,0 +1,22 @@ +export { + ID_LEN, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, +} from './constants'; + +export { + getAuthComplexity, + getInputComplexity, + getOutputComplexity, + getOwnerComplexity, + getSignerComplexity, + getTxComplexity, +} from './complexity'; diff --git a/src/vms/utils/calculateSpend/calculateSpend.ts b/src/vms/utils/calculateSpend/calculateSpend.ts index 9bbaf0736..3b90376e8 100644 --- a/src/vms/utils/calculateSpend/calculateSpend.ts +++ b/src/vms/utils/calculateSpend/calculateSpend.ts @@ -4,7 +4,15 @@ import type { Address } from '../../../serializable/fxs/common'; import { AddressMaps } from '../../../utils/addressMap'; import { compareTransferableOutputs } from '../../../utils/sort'; import type { SpendOptionsRequired } from '../../common'; -import type { UTXOCalculationFn, UTXOCalculationResult } from './models'; +import { + createEmptyDimensions, + type Dimensions, +} from '../../common/fees/dimensions'; +import type { + UTXOCalculationFn, + UTXOCalculationResult, + UTXOCalculationState, +} from './models'; export const defaultSpendResult = (): UTXOCalculationResult => ({ inputs: [], @@ -21,7 +29,7 @@ export const defaultSpendResult = (): UTXOCalculationResult => ({ * @param state the state from previous action function * @returns UTXOCalculationResult */ -function deepCopyState(state) { +function deepCopyState(state: UTXOCalculationState): UTXOCalculationState { return { ...state, amountsToBurn: new Map([...state.amountsToBurn]), @@ -56,13 +64,22 @@ export function calculateUTXOSpend( fromAddresses: Address[], options: SpendOptionsRequired, utxoCalculationFns: [UTXOCalculationFn, ...UTXOCalculationFn[]], + /** + * Complexity needed to calculate the fee for the transaction. + * + * Defaults to empty dimensions (ie 0). + * This means that either the tx type is unsupported, or the vm is unsupported. + * Essentially, if the complexity is empty, you should ignore it and calculate based on static fees. + */ + complexity: Dimensions = createEmptyDimensions(), ): UTXOCalculationResult { - const startState = { + const startState: UTXOCalculationState = { amountsToBurn, utxos, amountsToStake, fromAddresses, options, + complexity, ...defaultSpendResult(), }; const result = ( @@ -102,7 +119,7 @@ export function calculateUTXOSpend( stakeOutputs.sort(compareTransferableOutputs); return { stakeOutputs, ...state }; }, - function getAdressMaps({ inputs, inputUTXOs, ...state }) { + function getAddressMaps({ inputs, inputUTXOs, ...state }) { const addressMaps = AddressMaps.fromTransferableInputs( inputs, inputUTXOs, @@ -111,21 +128,20 @@ export function calculateUTXOSpend( ); return { inputs, inputUTXOs, ...state, addressMaps }; }, - ] as UTXOCalculationFn[] + ] satisfies UTXOCalculationFn[] ).reduce((state, next) => { // to prevent mutation we deep copy the arrays and maps before passing off to // the next operator return next(deepCopyState(state)); }, startState); - const { - /* eslint-disable @typescript-eslint/no-unused-vars */ - amountsToBurn: _amountsToBurn, - amountsToStake: _amountsToStake, - fromAddresses: _fromAddresses, - options: _options, - utxos: _utxos, - /* eslint-enable @typescript-eslint/no-unused-vars */ - ...calculationResults - } = result; - return calculationResults; + + const calculationResult: UTXOCalculationResult = { + inputs: result.inputs, + inputUTXOs: result.inputUTXOs, + stakeOutputs: result.stakeOutputs, + changeOutputs: result.changeOutputs, + addressMaps: result.addressMaps, + }; + + return calculationResult; } diff --git a/src/vms/utils/calculateSpend/models.ts b/src/vms/utils/calculateSpend/models.ts index 510494a6f..2acb8d136 100644 --- a/src/vms/utils/calculateSpend/models.ts +++ b/src/vms/utils/calculateSpend/models.ts @@ -6,6 +6,7 @@ import type { import type { Address } from '../../../serializable/fxs/common'; import type { SpendOptionsRequired } from '../../common'; import type { AddressMaps } from '../../../utils/addressMap'; +import type { Dimensions } from '../../common/fees/dimensions'; export interface UTXOCalculationResult { inputs: TransferableInput[]; @@ -21,6 +22,7 @@ export interface UTXOCalculationState extends UTXOCalculationResult { fromAddresses: Address[]; amountsToStake: Map; options: SpendOptionsRequired; + complexity: Dimensions; } export type UTXOCalculationFn = ( diff --git a/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts b/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts index 31159cb8a..9df9ad979 100644 --- a/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts +++ b/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts @@ -12,9 +12,9 @@ export const NoSigMatchError = new Error('No addresses match UTXO owners'); /** * The idea here is to verify that a given set of utxos contains any utxos that share addresses * with the fromAddresses array. If not we should be throwing an error as the tx is being formulated - * incoreectly + * incorrectly * - * @param set the utxo or data set, this can change depening on the calcFn + * @param set the utxo or data set, this can change depending on the calcFn * @param getTransferOutput a callback that takes a utxo and gets the output * @param fromAddresses the addresses the utxos should belong to * @param options From d4859420f582da72c102afe16db433cc3e7ecdd2 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 21 Aug 2024 16:25:38 -0600 Subject: [PATCH 11/39] feat: add new etnaBuilder for p-chain --- cspell.json | 2 +- src/serializable/avax/avaxTx.ts | 2 +- src/serializable/avax/baseTx.ts | 8 +- src/serializable/avax/transferableOutput.ts | 2 +- src/serializable/fxs/secp256k1/input.ts | 2 +- .../fxs/secp256k1/outputOwners.ts | 6 +- .../pvm/addPermissionlessDelegatorTx.ts | 2 +- .../pvm/addPermissionlessValidatorTx.ts | 2 +- src/utils/addressMap.ts | 6 +- src/utils/addressesFromBytes.ts | 2 +- src/utils/builderUtils.ts | 4 +- src/utils/serializeList.ts | 2 +- src/vms/common/builder.ts | 4 +- src/vms/common/defaultSpendOptions.ts | 2 +- src/vms/common/models.ts | 2 +- src/vms/pvm/builder.ts | 181 +-- src/vms/pvm/etnaBuilder.ts | 1202 +++++++++++++++++ src/vms/pvm/txs/fee/complexity.ts | 4 +- .../utils/calculateSpend/calculateSpend.ts | 13 - src/vms/utils/calculateSpend/models.ts | 2 - 20 files changed, 1236 insertions(+), 214 deletions(-) create mode 100644 src/vms/pvm/etnaBuilder.ts diff --git a/cspell.json b/cspell.json index 9dfe4f329..1c6e2b58a 100644 --- a/cspell.json +++ b/cspell.json @@ -19,5 +19,5 @@ "ignoreRegExpList": ["/.*[0-9].*/"], "language": "en", "minWordLength": 5, - "words": ["avalabs", "locktime", "stakeable", "unstakeable", "utxo", "utxos"] + "words": ["amounter", "avalabs", "locktime", "stakeable", "unstakeable", "utxo", "utxos"] } diff --git a/src/serializable/avax/avaxTx.ts b/src/serializable/avax/avaxTx.ts index 48d24a690..abcae7bad 100644 --- a/src/serializable/avax/avaxTx.ts +++ b/src/serializable/avax/avaxTx.ts @@ -5,7 +5,7 @@ import type { TransferableInput } from './transferableInput'; export abstract class AvaxTx extends Transaction { abstract baseTx?: BaseTx; - getInputs(): TransferableInput[] { + getInputs(): readonly TransferableInput[] { return this.baseTx?.inputs ?? []; } getBlockchainId() { diff --git a/src/serializable/avax/baseTx.ts b/src/serializable/avax/baseTx.ts index ceab6d8ea..b7ef88dbc 100644 --- a/src/serializable/avax/baseTx.ts +++ b/src/serializable/avax/baseTx.ts @@ -19,8 +19,8 @@ export class BaseTx { constructor( public readonly NetworkId: Int, public readonly BlockchainId: Id, - public readonly outputs: TransferableOutput[], - public readonly inputs: TransferableInput[], + public readonly outputs: readonly TransferableOutput[], + public readonly inputs: readonly TransferableInput[], public readonly memo: Bytes, ) {} @@ -45,8 +45,8 @@ export class BaseTx { static fromNative( networkId: number, blockchainId: string, - outputs: TransferableOutput[], - inputs: TransferableInput[], + outputs: readonly TransferableOutput[], + inputs: readonly TransferableInput[], memo: Uint8Array, ) { return new BaseTx( diff --git a/src/serializable/avax/transferableOutput.ts b/src/serializable/avax/transferableOutput.ts index 1b4296675..40ebd2f5c 100644 --- a/src/serializable/avax/transferableOutput.ts +++ b/src/serializable/avax/transferableOutput.ts @@ -23,7 +23,7 @@ export class TransferableOutput { static fromNative( assetId: string, amt: bigint, - addresses: Uint8Array[], + addresses: readonly Uint8Array[], locktime?: bigint, threshold?: number, ) { diff --git a/src/serializable/fxs/secp256k1/input.ts b/src/serializable/fxs/secp256k1/input.ts index b31610399..9ba4f7af4 100644 --- a/src/serializable/fxs/secp256k1/input.ts +++ b/src/serializable/fxs/secp256k1/input.ts @@ -16,7 +16,7 @@ export class Input { constructor(private readonly sigIndices: Int[]) {} - static fromNative(sigIndicies: number[]) { + static fromNative(sigIndicies: readonly number[]) { return new Input(sigIndicies.map((i) => new Int(i))); } diff --git a/src/serializable/fxs/secp256k1/outputOwners.ts b/src/serializable/fxs/secp256k1/outputOwners.ts index 560209d27..4216e3aaa 100644 --- a/src/serializable/fxs/secp256k1/outputOwners.ts +++ b/src/serializable/fxs/secp256k1/outputOwners.ts @@ -21,7 +21,11 @@ export class OutputOwners { public readonly addrs: Address[], ) {} - static fromNative(address: Uint8Array[], locktime = 0n, threshold = 1) { + static fromNative( + address: readonly Uint8Array[], + locktime = 0n, + threshold = 1, + ) { return new OutputOwners( new BigIntPr(locktime), new Int(threshold), diff --git a/src/serializable/pvm/addPermissionlessDelegatorTx.ts b/src/serializable/pvm/addPermissionlessDelegatorTx.ts index 5be3a282f..0e3e5352f 100644 --- a/src/serializable/pvm/addPermissionlessDelegatorTx.ts +++ b/src/serializable/pvm/addPermissionlessDelegatorTx.ts @@ -21,7 +21,7 @@ export class AddPermissionlessDelegatorTx extends PVMTx { constructor( public readonly baseTx: BaseTx, public readonly subnetValidator: SubnetValidator, - public readonly stake: TransferableOutput[], + public readonly stake: readonly TransferableOutput[], public readonly delegatorRewardsOwner: Serializable, ) { super(); diff --git a/src/serializable/pvm/addPermissionlessValidatorTx.ts b/src/serializable/pvm/addPermissionlessValidatorTx.ts index 12dfb9155..9d0f99f23 100644 --- a/src/serializable/pvm/addPermissionlessValidatorTx.ts +++ b/src/serializable/pvm/addPermissionlessValidatorTx.ts @@ -24,7 +24,7 @@ export class AddPermissionlessValidatorTx extends PVMTx { public readonly baseTx: BaseTx, public readonly subnetValidator: SubnetValidator, public readonly signer: Signer | SignerEmpty, - public readonly stake: TransferableOutput[], + public readonly stake: readonly TransferableOutput[], public readonly validatorRewardsOwner: Serializable, public readonly delegatorRewardsOwner: Serializable, public readonly shares: Int, diff --git a/src/utils/addressMap.ts b/src/utils/addressMap.ts index 0a6c52e06..b1bba060a 100644 --- a/src/utils/addressMap.ts +++ b/src/utils/addressMap.ts @@ -96,10 +96,10 @@ export class AddressMaps { // this is a stopgap to quickly fix AddressMap not deriving the order post sorting TransferableInputs. Can probably // be simplified a lot by just deriving the sigIndicies right before returning the unsingedTx static fromTransferableInputs( - inputs: TransferableInput[], - inputUtxos: Utxo[], + inputs: readonly TransferableInput[], + inputUtxos: readonly Utxo[], minIssuanceTime: bigint, - fromAddressesBytes?: Uint8Array[], + fromAddressesBytes?: readonly Uint8Array[], ) { const utxoMap = inputUtxos.reduce((agg, utxo) => { return agg.set(utxo.utxoId.ID(), utxo); diff --git a/src/utils/addressesFromBytes.ts b/src/utils/addressesFromBytes.ts index ffa53525e..cea2c5282 100644 --- a/src/utils/addressesFromBytes.ts +++ b/src/utils/addressesFromBytes.ts @@ -1,5 +1,5 @@ import { Address } from '../serializable/fxs/common'; -export function addressesFromBytes(bytes: Uint8Array[]): Address[] { +export function addressesFromBytes(bytes: readonly Uint8Array[]): Address[] { return bytes.map((b) => new Address(b)); } diff --git a/src/utils/builderUtils.ts b/src/utils/builderUtils.ts index 79a345322..d60be0387 100644 --- a/src/utils/builderUtils.ts +++ b/src/utils/builderUtils.ts @@ -11,8 +11,8 @@ type GetImportedInputsFromUtxosOutput = { }; export const getImportedInputsFromUtxos = ( - utxos: Utxo[], - fromAddressesBytes: Uint8Array[], + utxos: readonly Utxo[], + fromAddressesBytes: readonly Uint8Array[], minIssuanceTime: bigint, ): GetImportedInputsFromUtxosOutput => { const fromAddresses = addressesFromBytes(fromAddressesBytes); diff --git a/src/utils/serializeList.ts b/src/utils/serializeList.ts index e62ef200f..1ddacfb28 100644 --- a/src/utils/serializeList.ts +++ b/src/utils/serializeList.ts @@ -54,7 +54,7 @@ export const unpackCodecList = { }; export const packList = ( - serializables: Serializable[], + serializables: readonly Serializable[], codec: Codec, ): Uint8Array => { return concatBytes( diff --git a/src/vms/common/builder.ts b/src/vms/common/builder.ts index 9ba179c7f..662ee08fc 100644 --- a/src/vms/common/builder.ts +++ b/src/vms/common/builder.ts @@ -31,8 +31,8 @@ export const baseTxUnsafeAvm = ( */ export const baseTxUnsafePvm = ( context: Context, - changeOutputs: TransferableOutput[], - inputs: TransferableInput[], + changeOutputs: readonly TransferableOutput[], + inputs: readonly TransferableInput[], memo: Uint8Array, ) => { return AvaxBaseTx.fromNative( diff --git a/src/vms/common/defaultSpendOptions.ts b/src/vms/common/defaultSpendOptions.ts index e9e0a4fbd..89e7d11b3 100644 --- a/src/vms/common/defaultSpendOptions.ts +++ b/src/vms/common/defaultSpendOptions.ts @@ -1,7 +1,7 @@ import type { SpendOptions, SpendOptionsRequired } from './models'; export const defaultSpendOptions = ( - fromAddress: Uint8Array[], + fromAddress: readonly Uint8Array[], options?: SpendOptions, ): SpendOptionsRequired => { return { diff --git a/src/vms/common/models.ts b/src/vms/common/models.ts index f7b043c30..2358725df 100644 --- a/src/vms/common/models.ts +++ b/src/vms/common/models.ts @@ -1,6 +1,6 @@ export type SpendOptions = { minIssuanceTime?: bigint; - changeAddresses?: Uint8Array[]; + changeAddresses?: readonly Uint8Array[]; threshold?: number; memo?: Uint8Array; locktime?: bigint; diff --git a/src/vms/pvm/builder.ts b/src/vms/pvm/builder.ts index 8eaaca253..7b2176c6a 100644 --- a/src/vms/pvm/builder.ts +++ b/src/vms/pvm/builder.ts @@ -43,40 +43,6 @@ import { import { NodeId } from '../../serializable/fxs/common/nodeId'; import { createSignerOrSignerEmptyFromStrings } from '../../serializable/pvm/signer'; import { baseTxUnsafePvm } from '../common'; -import type { Dimensions } from '../common/fees/dimensions'; -import { - addDimensions, - createDimensions, - createEmptyDimensions, -} from '../common/fees/dimensions'; -import { - ID_LEN, - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, - INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, - INTRINSIC_BASE_TX_COMPLEXITIES, - INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, - INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, - INTRINSIC_EXPORT_TX_COMPLEXITIES, - INTRINSIC_IMPORT_TX_COMPLEXITIES, - INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, - INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, - getAuthComplexity, - getInputComplexity, - getOutputComplexity, - getOwnerComplexity, - getSignerComplexity, -} from './txs/fee'; - -/* - Builder is useful for building transactions that are specific to a chain. - */ - -const getMemoComplexity = ( - spendOptions: Required, -): Dimensions => { - return createDimensions(spendOptions.memo.length, 0, 0, 0); -}; /** * @param fromAddresses - used for selecting which utxos are signable @@ -104,16 +70,6 @@ export function newBaseTx( toBurn.set(assetId, (toBurn.get(assetId) || 0n) + out.output.amount()); }); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const outputComplexity = getOutputComplexity(outputs); - - const complexity = addDimensions( - INTRINSIC_BASE_TX_COMPLEXITIES, - memoComplexity, - outputComplexity, - ); - const { inputs, inputUTXOs, changeOutputs, addressMaps } = calculateUTXOSpend( toBurn, undefined, @@ -121,7 +77,6 @@ export function newBaseTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs, useConsolidateOutputs], - complexity, ); const allOutputs = [...outputs, ...changeOutputs]; @@ -145,7 +100,7 @@ export function newBaseTx( @param threshold - the threshold to write on the utxo @param locktime - the locktime to write onto the utxo - @returns a unsignedTx + @returns UnsignedTx */ export function newImportTx( context: Context, @@ -203,19 +158,6 @@ export function newImportTx( ); } - const memoComplexity = getMemoComplexity(defaultedOptions); - - const inputComplexity = getInputComplexity(importedInputs); - - const outputComplexity = getOutputComplexity(outputs); - - const complexity = addDimensions( - INTRINSIC_IMPORT_TX_COMPLEXITIES, - memoComplexity, - inputComplexity, - outputComplexity, - ); - let inputs: TransferableInput[] = []; let changeOutputs: TransferableOutput[] = []; @@ -231,7 +173,6 @@ export function newImportTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs], - complexity, ); inputs = spendRes.inputs; changeOutputs = spendRes.changeOutputs; @@ -325,7 +266,6 @@ export function newAddValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], - createEmptyDimensions(), ); const validatorTx = new AddValidatorTx( @@ -351,7 +291,7 @@ export function newAddValidatorTx( * @param utxos list of utxos to choose from * @param outputs list of outputs to create. * @param options used for filtering UTXO's - * @returns unsignedTx containing an exportTx + * @returns UnsignedTx containing an exportTx */ export function newExportTx( @@ -367,16 +307,6 @@ export function newExportTx( const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); const toBurn = getToBurn(context, outputs, context.baseTxFee); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const outputComplexity = getOutputComplexity(outputs); - - const complexity = addDimensions( - INTRINSIC_EXPORT_TX_COMPLEXITIES, - memoComplexity, - outputComplexity, - ); - const { inputs, changeOutputs, addressMaps, inputUTXOs } = calculateUTXOSpend( toBurn, undefined, @@ -384,7 +314,6 @@ export function newExportTx( fromAddresses, defaultedOptions, [useUnlockedUTXOs], - complexity, ); outputs.sort(compareTransferableOutputs); @@ -418,7 +347,7 @@ export function newExportTx( * @param options - used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns unsignedTx + * @returns UnsignedTx */ export function newAddDelegatorTx( @@ -448,7 +377,6 @@ export function newAddDelegatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], - createEmptyDimensions(), ); const addDelegatorTx = new AddDelegatorTx( @@ -476,7 +404,7 @@ export function newAddDelegatorTx( * @param options used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns unsignedTx + * @returns UnsignedTx */ export function newCreateSubnetTx( context: Context, @@ -489,18 +417,6 @@ export function newCreateSubnetTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const ownerComplexity = getOwnerComplexity( - OutputOwners.fromNative(subnetOwners, locktime, threshold), - ); - - const complexity = addDimensions( - INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, - memoComplexity, - ownerComplexity, - ); - const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.createSubnetTxFee]]), undefined, @@ -508,7 +424,6 @@ export function newCreateSubnetTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - complexity, ); const createSubnetTx = new CreateSubnetTx( @@ -558,24 +473,6 @@ export function newCreateBlockchainTx( new TextEncoder().encode(JSON.stringify(genesisData)), ); - const dynamicComplexity = createDimensions( - fxIds.length * ID_LEN + - chainName.length + - genesisBytes.length + - defaultedOptions.memo.length, - 0, - 0, - 0, - ); - - const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); - - const complexity = addDimensions( - INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, - dynamicComplexity, - authComplexity, - ); - const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), undefined, @@ -583,7 +480,6 @@ export function newCreateBlockchainTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - complexity, ); const createChainTx = new CreateChainTx( @@ -619,16 +515,6 @@ export function newAddSubnetValidatorTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); - - const complexity = addDimensions( - INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, - memoComplexity, - authComplexity, - ); - const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), undefined, @@ -636,7 +522,6 @@ export function newAddSubnetValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - complexity, ); const addSubnetValidatorTx = new AddSubnetValidatorTx( @@ -670,16 +555,6 @@ export function newRemoveSubnetValidatorTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); - - const complexity = addDimensions( - INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, - memoComplexity, - authComplexity, - ); - const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.baseTxFee]]), undefined, @@ -687,7 +562,6 @@ export function newRemoveSubnetValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - complexity, ); const removeSubnetValidatorTx = new RemoveSubnetValidatorTx( @@ -773,20 +647,6 @@ export function newAddPermissionlessValidatorTx( 0n, ); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const signerComplexity = getSignerComplexity(signer); - const validatorOwnerComplexity = getOwnerComplexity(validatorOutputOwners); - const delegatorOwnerComplexity = getOwnerComplexity(delegatorOutputOwners); - - const complexity = addDimensions( - INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, - memoComplexity, - signerComplexity, - validatorOwnerComplexity, - delegatorOwnerComplexity, - ); - const { addressMaps, changeOutputs, inputUTXOs, inputs, stakeOutputs } = calculateUTXOSpend( toBurn, @@ -795,7 +655,6 @@ export function newAddPermissionlessValidatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], - complexity, ); const validatorTx = new AddPermissionlessValidatorTx( @@ -879,16 +738,6 @@ export function newAddPermissionlessDelegatorTx( threshold, ); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const ownerComplexity = getOwnerComplexity(delegatorRewardsOwner); - - const complexity = addDimensions( - INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, - memoComplexity, - ownerComplexity, - ); - const { addressMaps, changeOutputs, inputUTXOs, inputs, stakeOutputs } = calculateUTXOSpend( toBurn, @@ -897,7 +746,6 @@ export function newAddPermissionlessDelegatorTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useSpendableLockedUTXOs, useUnlockedUTXOs, useConsolidateOutputs], - complexity, ); const delegatorTx = new AddPermissionlessDelegatorTx( @@ -941,7 +789,7 @@ export function newAddPermissionlessDelegatorTx( * @param uptimeRequirement the minimum percentage a validator must be online and responsive to receive a reward * @param subnetAuth specifies indices of existing subnet owners * @param options used for filtering utxos - * @returns unsignedTx containing a TransformSubnetTx + * @returns UnsignedTx containing a TransformSubnetTx */ export function newTransformSubnetTx( context: Context, @@ -973,7 +821,6 @@ export function newTransformSubnetTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - createEmptyDimensions(), ); return new UnsignedTx( @@ -1016,7 +863,7 @@ export function newTransformSubnetTx( * @param options used for filtering utxos * @param threshold Opional. The number of signatures required to spend the funds in the resultant reward UTXO. Default 1. * @param locktime Optional. The locktime field created in the resulting reward outputs - * @returns unsignedTx containing a TransferSubnetOwnershipTx + * @returns UnsignedTx containing a TransferSubnetOwnershipTx */ export function newTransferSubnetOwnershipTx( context: Context, @@ -1031,21 +878,6 @@ export function newTransferSubnetOwnershipTx( ) { const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const memoComplexity = getMemoComplexity(defaultedOptions); - - const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); - - const ownerComplexity = getOwnerComplexity( - OutputOwners.fromNative(subnetOwners, locktime, threshold), - ); - - const complexity = addDimensions( - INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, - memoComplexity, - authComplexity, - ownerComplexity, - ); - const { inputs, addressMaps, changeOutputs, inputUTXOs } = calculateUTXOSpend( new Map([[context.avaxAssetID, context.baseTxFee]]), undefined, @@ -1053,7 +885,6 @@ export function newTransferSubnetOwnershipTx( addressesFromBytes(fromAddressesBytes), defaultedOptions, [useUnlockedUTXOs], - complexity, ); return new UnsignedTx( diff --git a/src/vms/pvm/etnaBuilder.ts b/src/vms/pvm/etnaBuilder.ts new file mode 100644 index 000000000..0b4ca345f --- /dev/null +++ b/src/vms/pvm/etnaBuilder.ts @@ -0,0 +1,1202 @@ +/** + * @module + * + * This module contains builder functions which are responsible for building + * PVM transactions post e-upgrade (etna), which uses dynamic fees based on transaction complexity. + */ + +import { PlatformChainID, PrimaryNetworkID } from '../../constants/networkIDs'; +import type { Address } from '../../serializable'; +import { Input, NodeId, OutputOwners, Stringpr } from '../../serializable'; +import { + Bytes, + Id, + Int, + TransferableInput, + TransferableOutput, +} from '../../serializable'; +import { BaseTx as AvaxBaseTx } from '../../serializable/avax'; +import type { Utxo } from '../../serializable/avax/utxo'; +import { + AddPermissionlessDelegatorTx, + AddPermissionlessValidatorTx, + AddSubnetValidatorTx, + BaseTx, + CreateChainTx, + CreateSubnetTx, + ExportTx, + ImportTx, + RemoveSubnetValidatorTx, + SubnetValidator, + TransferSubnetOwnershipTx, +} from '../../serializable/pvm'; +import { createSignerOrSignerEmptyFromStrings } from '../../serializable/pvm/signer'; +import { AddressMaps, addressesFromBytes } from '../../utils'; +import { getImportedInputsFromUtxos } from '../../utils/builderUtils'; +import { compareTransferableOutputs } from '../../utils/sort'; +import { baseTxUnsafePvm, type SpendOptions, UnsignedTx } from '../common'; +import { defaultSpendOptions } from '../common/defaultSpendOptions'; +import type { Dimensions } from '../common/fees/dimensions'; +import { addDimensions, createDimensions } from '../common/fees/dimensions'; +import type { Context } from '../context'; +import { + ID_LEN, + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_BASE_TX_COMPLEXITIES, + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + INTRINSIC_EXPORT_TX_COMPLEXITIES, + INTRINSIC_IMPORT_TX_COMPLEXITIES, + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + getAuthComplexity, + getInputComplexity, + getOutputComplexity, + getOwnerComplexity, + getSignerComplexity, +} from './txs/fee'; + +type SpendResult = Readonly<{ + changeOutputs: readonly TransferableOutput[]; + inputs: readonly TransferableInput[]; + stakeOutputs: readonly TransferableOutput[]; +}>; + +type SpendProps = Readonly<{ + /** + * Contains the currently accrued transaction complexity that + * will be used to calculate the required fees to be burned. + */ + complexity: Dimensions; + /** + * Contains the amount of extra AVAX that spend can produce in + * the change outputs in addition to the consumed and not burned AVAX. + */ + excessAVAX: bigint; + /** + * List of Addresses that are used for selecting which UTXOs are signable. + */ + fromAddresses: readonly Address[]; + /** + * Optionally specifies the output owners to use for the unlocked + * AVAX change output if no additional AVAX was needed to be burned. + * If this value is `undefined`, the default change owner is used. + */ + ownerOverride?: OutputOwners; + spendOptions: Required; + /** + * Maps `assetID` to the amount of the asset to spend without + * producing an output. This is typically used for fees. + * However, it can also be used to consume some of an asset that + * will be produced in separate outputs, such as ExportedOutputs. + * + * Only unlocked UTXOs are able to be burned here. + */ + toBurn: Map; + /** + * Maps `assetID` to the amount of the asset to spend and place info + * the staked outputs. First locked UTXOs are attempted to be used for + * these funds, and then unlocked UTXOs will be attempted to be used. + * There is no preferential ordering on the unlock times. + */ + toStake?: Map; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +// TODO: Move this to it's own file. +const spend = ({ + complexity, + excessAVAX, + fromAddresses, + ownerOverride, + spendOptions, + toBurn, + toStake, + utxos, +}: SpendProps): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + return [new Error('Not implemented'), null]; +}; + +const getMemoComplexity = ( + spendOptions: Required, +): Dimensions => { + return createDimensions(spendOptions.memo.length, 0, 0, 0); +}; + +/** + * Common properties used in all PVM transaction builder functions. + */ +type CommonTxProps = Readonly<{ + /** + * List of addresses that are used for selecting which UTXOs are signable. + */ + fromAddressesBytes: readonly Uint8Array[]; + options?: SpendOptions; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +type TxProps> = CommonTxProps & Readonly; + +type TxBuilderFn>> = ( + props: T, + context: Context, +) => UnsignedTx; + +export type NewBaseTxProps = TxProps<{ + /** + * The desired output (change outputs will be added to them automatically). + */ + outputs: readonly TransferableOutput[]; +}>; + +/** + * Creates a new unsigned PVM base transaction (`BaseTx`) using calculated dynamic fees. + * + * @param props {NewBaseTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newBaseTx: TxBuilderFn = ( + { fromAddressesBytes, options, outputs, utxos }, + context, +) => { + const fromAddresses = addressesFromBytes(fromAddressesBytes); + const defaultedOptions = defaultSpendOptions( + [...fromAddressesBytes], + options, + ); + const toBurn = new Map([ + [context.avaxAssetID, context.baseTxFee], + ]); + + outputs.forEach((out) => { + const assetId = out.assetId.value(); + toBurn.set(assetId, (toBurn.get(assetId) || 0n) + out.output.amount()); + }); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_BASE_TX_COMPLEXITIES, + memoComplexity, + outputComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + const allOutputs = [...outputs, ...changeOutputs].sort( + compareTransferableOutputs, + ); + + return new UnsignedTx( + new BaseTx( + baseTxUnsafePvm(context, allOutputs, inputs, defaultedOptions.memo), + ), + inputUTXOs, + addressMaps, + ); +}; + +export type NewImportTxProps = TxProps<{ + /** + * The locktime to write onto the UTXO. + */ + locktime: bigint; + /** + * Base58 string of the source chain ID. + */ + sourceChainId: string; + /** + * The threshold to write on the UTXO. + */ + threshold: number; + /** + * List of addresses to import into. + */ + toAddresses: readonly Uint8Array[]; +}>; + +/** + * Creates a new unsigned PVM import transaction (`ImportTx`) using calculated dynamic fees. + * + * @param props {NewImportTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newImportTx: TxBuilderFn = ( + { + fromAddressesBytes, + locktime, + options, + sourceChainId, + threshold, + toAddresses, + utxos, + }, + context, +) => { + const fromAddresses = addressesFromBytes(fromAddressesBytes); + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + utxos = utxos.filter( + // Currently - only AVAX is allowed to be imported to the P-chain + (utxo) => utxo.assetId.toString() === context.avaxAssetID, + ); + + const { importedAmounts, importedInputs, inputUTXOs } = + getImportedInputsFromUtxos( + utxos, + fromAddressesBytes, + defaultedOptions.minIssuanceTime, + ); + + const importedAvax = importedAmounts[context.avaxAssetID] ?? 0n; + + importedInputs.sort(TransferableInput.compare); + const addressMaps = AddressMaps.fromTransferableInputs( + importedInputs, + utxos, + defaultedOptions.minIssuanceTime, + fromAddressesBytes, + ); + if (!importedInputs.length) { + throw new Error('no UTXOs available to import'); + } + + const outputs: TransferableOutput[] = []; + + for (const [assetID, amount] of Object.entries(importedAmounts)) { + if (assetID === context.avaxAssetID) { + continue; + } + + outputs.push( + TransferableOutput.fromNative( + assetID, + amount, + toAddresses, + locktime, + threshold, + ), + ); + } + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const inputComplexity = getInputComplexity(importedInputs); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_IMPORT_TX_COMPLEXITIES, + memoComplexity, + inputComplexity, + outputComplexity, + ); + + let inputs: TransferableInput[] = []; + let changeOutputs: TransferableOutput[] = []; + + if (importedAvax < context.baseTxFee) { + const toBurn = new Map([ + [context.avaxAssetID, context.baseTxFee - importedAvax], + ]); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }); + + if (error) { + throw error; + } + + inputs = [...spendResults.inputs]; + changeOutputs = [...spendResults.changeOutputs]; + } else if (importedAvax > context.baseTxFee) { + changeOutputs.push( + TransferableOutput.fromNative( + context.avaxAssetID, + importedAvax - context.baseTxFee, + toAddresses, + locktime, + threshold, + ), + ); + } + + return new UnsignedTx( + new ImportTx( + new AvaxBaseTx( + new Int(context.networkID), + PlatformChainID, + changeOutputs, + inputs, + new Bytes(defaultedOptions.memo), + ), + Id.fromString(sourceChainId), + importedInputs, + ), + inputUTXOs, + addressMaps, + ); +}; + +export type NewExportTxProps = TxProps<{ + /** + * Base58 string of the destination chain ID. + */ + destinationChainId: string; + /** + * List of outputs to create. + */ + outputs: readonly TransferableOutput[]; +}>; + +/** + * Creates a new unsigned PVM export transaction (`ExportTx`) using calculated dynamic fees. + * + * @param props {NewExportTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newExportTx: TxBuilderFn = ( + { destinationChainId, fromAddressesBytes, options, outputs, utxos }, + context, +) => { + const fromAddresses = addressesFromBytes(fromAddressesBytes); + + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + const toBurn = new Map([ + [context.avaxAssetID, context.baseTxFee], + ]); + + outputs.forEach((output) => { + const assetId = output.assetId.value(); + toBurn.set(assetId, (toBurn.get(assetId) || 0n) + output.output.amount()); + }); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const outputComplexity = getOutputComplexity(outputs); + + const complexity = addDimensions( + INTRINSIC_EXPORT_TX_COMPLEXITIES, + memoComplexity, + outputComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + return new UnsignedTx( + new ExportTx( + new AvaxBaseTx( + new Int(context.networkID), + PlatformChainID, + changeOutputs, + inputs, + new Bytes(defaultedOptions.memo), + ), + Id.fromString(destinationChainId), + [...outputs].sort(compareTransferableOutputs), + ), + inputUTXOs, + addressMaps, + ); +}; + +export type NewCreateSubnetTxProps = TxProps<{ + /** + * The locktime to write onto the UTXO. + */ + locktime: bigint; + subnetOwners: readonly Uint8Array[]; + /** + * The threshold to write on the UTXO. + */ + threshold: number; +}>; + +/** + * Creates a new unsigned PVM create subnet transaction (`CreateSubnetTx`) using calculated dynamic fees. + * + * @param props {NewCreateSubnetTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newCreateSubnetTx: TxBuilderFn = ( + { fromAddressesBytes, locktime, options, subnetOwners, threshold, utxos }, + context, +) => { + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const ownerComplexity = getOwnerComplexity( + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ); + + const complexity = addDimensions( + INTRINSIC_CREATE_SUBNET_TX_COMPLEXITIES, + memoComplexity, + ownerComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.createSubnetTxFee]]), + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + const createSubnetTx = new CreateSubnetTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ); + + return new UnsignedTx(createSubnetTx, inputUTXOs, addressMaps); +}; + +export type NewCreateChainTxProps = TxProps<{ + /** + * A human readable name for the chain. + */ + chainName: string; + /** + * IDs of the feature extensions running on the new chain. + */ + fxIds: readonly string[]; + /** + * JSON config for the genesis data. + */ + genesisData: Record; + /** + * Indices of subnet owners. + */ + subnetAuth: readonly number[]; + /** + * ID of the subnet (Avalanche L1) that validates this chain. + */ + subnetId: string; + /** + * ID of the VM running on the new chain. + */ + vmId: string; +}>; + +/** + * Creates a new unsigned PVM create chain transaction (`CreateChainTx`) using calculated dynamic fees. + * + * @param props {NewCreateChainTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newCreateChainTx: TxBuilderFn = ( + { + chainName, + fromAddressesBytes, + fxIds, + genesisData, + options, + subnetAuth, + subnetId, + utxos, + vmId, + }, + context, +) => { + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const genesisBytes = new Bytes( + new TextEncoder().encode(JSON.stringify(genesisData)), + ); + + const subnetAuthInput = Input.fromNative(subnetAuth); + + const dynamicComplexity = createDimensions( + fxIds.length * ID_LEN + + chainName.length + + genesisBytes.length + + defaultedOptions.memo.length, + 0, + 0, + 0, + ); + + const authComplexity = getAuthComplexity(subnetAuthInput); + + const complexity = addDimensions( + INTRINSIC_CREATE_CHAIN_TX_COMPLEXITIES, + dynamicComplexity, + authComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + const createChainTx = new CreateChainTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + Id.fromString(subnetId), + new Stringpr(chainName), + Id.fromString(vmId), + fxIds.map(Id.fromString.bind(Id)), + genesisBytes, + subnetAuthInput, + ); + + return new UnsignedTx(createChainTx, inputUTXOs, addressMaps); +}; + +export type NewAddSubnetValidatorTxProps = TxProps<{ + end: bigint; + nodeId: string; + start: bigint; + /** + * Indices of subnet owners. + */ + subnetAuth: readonly number[]; + /** + * ID of the subnet (Avalanche L1) that validates this chain. + */ + subnetId: string; + weight: bigint; +}>; + +/** + * Creates a new unsigned PVM add subnet validator transaction + * (`AddSubnetValidatorTx`) using calculated dynamic fees. + * + * @param props {NewAddSubnetValidatorTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newAddSubnetValidatorTx: TxBuilderFn< + NewAddSubnetValidatorTxProps +> = ( + { + end, + fromAddressesBytes, + nodeId, + options, + start, + subnetAuth, + subnetId, + utxos, + weight, + }, + context, +) => { + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const complexity = addDimensions( + INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + const addSubnetValidatorTx = new AddSubnetValidatorTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + SubnetValidator.fromNative( + nodeId, + start, + end, + weight, + Id.fromString(subnetId), + ), + Input.fromNative(subnetAuth), + ); + + return new UnsignedTx(addSubnetValidatorTx, inputUTXOs, addressMaps); +}; + +export type NewRemoveSubnetValidatorTxProps = TxProps<{ + nodeId: string; + /** + * Indices of subnet owners. + */ + subnetAuth: readonly number[]; + /** + * ID of the subnet (Avalanche L1) that validates this chain. + */ + subnetId: string; +}>; + +/** + * Creates a new unsigned PVM remove subnet validator transaction + * (`RemoveSubnetValidatorTx`) using calculated dynamic fees. + * + * @param props {NewRemoveSubnetValidatorTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newRemoveSubnetValidatorTx: TxBuilderFn< + NewRemoveSubnetValidatorTxProps +> = ( + { fromAddressesBytes, nodeId, options, subnetAuth, subnetId, utxos }, + context, +) => { + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const complexity = addDimensions( + INTRINSIC_REMOVE_SUBNET_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + const removeSubnetValidatorTx = new RemoveSubnetValidatorTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + NodeId.fromString(nodeId), + Id.fromString(subnetId), + Input.fromNative(subnetAuth), + ); + + return new UnsignedTx(removeSubnetValidatorTx, inputUTXOs, addressMaps); +}; + +export type NewAddPermissionlessValidatorTxProps = TxProps<{ + delegatorRewardsOwner: readonly Uint8Array[]; + /** + * The Unix time based on p-chain timestamp when the validator + * stops validating the Primary Network (and staked AVAX is returned). + */ + end: bigint; + /** + * Optional. The number locktime field created in the resulting reward outputs. + * @default 0n + */ + locktime?: bigint; + /** + * The node ID of the validator being added. + */ + nodeId: string; + /** + * The BLS public key. + */ + publicKey: Uint8Array; + /** + * The addresses which will receive the rewards from the delegated stake. + * Given addresses will share the reward UTXO. + */ + rewardAddresses: readonly Uint8Array[]; + /** + * A number for the percentage times 10,000 of reward to be given to the + * validator when someone delegates to them. + */ + shares: number; + /** + * The BLS signature. + */ + signature: Uint8Array; + /** + * Which asset to stake. Defaults to AVAX. + */ + stakingAssetId?: string; + /** + * The Unix time based on p-chain timestamp when the validator + * starts validating the Primary Network. + */ + start: bigint; + /** + * ID of the subnet (Avalanche L1) that validates this chain. + */ + subnetId: string; + /** + * Optional. The number of signatures required to spend the funds in the + * resultant reward UTXO. + * + * @default 1 + */ + threshold?: number; + /** + * The amount being locked for validation in nAVAX. + */ + weight: bigint; +}>; + +/** + * Creates a new unsigned PVM add permissionless validator transaction + * (`AddPermissionlessValidatorTx`) using calculated dynamic fees. + * + * @param props {NewAddPermissionlessValidatorTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newAddPermissionlessValidatorTx: TxBuilderFn< + NewAddPermissionlessValidatorTxProps +> = ( + { + delegatorRewardsOwner, + end, + fromAddressesBytes, + locktime = 0n, + nodeId, + options, + publicKey, + rewardAddresses, + shares, + signature, + stakingAssetId, + start, + subnetId, + threshold = 1, + utxos, + weight, + }, + context, +) => { + const isPrimaryNetwork = subnetId === PrimaryNetworkID.toString(); + const fee = isPrimaryNetwork + ? context.addPrimaryNetworkValidatorFee + : context.addSubnetValidatorFee; + const toBurn = new Map([[context.avaxAssetID, fee]]); + + const assetId = stakingAssetId ?? context.avaxAssetID; + + // Check if we use correct asset if on primary network + if (isPrimaryNetwork && assetId !== context.avaxAssetID) + throw new Error('Staking asset ID must be AVAX for the primary network.'); + + const toStake = new Map([[assetId, weight]]); + + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const signer = createSignerOrSignerEmptyFromStrings(publicKey, signature); + const validatorOutputOwners = OutputOwners.fromNative( + rewardAddresses, + locktime, + threshold, + ); + const delegatorOutputOwners = OutputOwners.fromNative( + delegatorRewardsOwner, + 0n, + ); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const signerComplexity = getSignerComplexity(signer); + const validatorOwnerComplexity = getOwnerComplexity(validatorOutputOwners); + const delegatorOwnerComplexity = getOwnerComplexity(delegatorOutputOwners); + + const complexity = addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, + memoComplexity, + signerComplexity, + validatorOwnerComplexity, + delegatorOwnerComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn, + toStake, + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs, stakeOutputs } = spendResults; + + const validatorTx = new AddPermissionlessValidatorTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + SubnetValidator.fromNative( + nodeId, + start, + end, + weight, + Id.fromString(subnetId), + ), + signer, + stakeOutputs, + validatorOutputOwners, + delegatorOutputOwners, + new Int(shares), + ); + return new UnsignedTx(validatorTx, inputUTXOs, addressMaps); +}; + +export type NewAddPermissionlessDelegatorTxProps = TxProps<{ + /** + * The Unix time based on p-chain timestamp when the delegation stops + * (and staked AVAX is returned). + */ + end: bigint; + /** + * Optional. The number locktime field created in the resulting reward outputs. + * @default 0n + */ + locktime?: bigint; + /** + * The node ID of the validator being delegated to. + */ + nodeId: string; + /** + * The addresses which will receive the rewards from the delegated stake. + * Given addresses will share the reward UTXO. + */ + rewardAddresses: readonly Uint8Array[]; + /** + * Which asset to stake. Defaults to AVAX. + */ + stakingAssetId?: string; + /** + * The Unix time based on p-chain timestamp when the delegation starts. + */ + start: bigint; + /** + * ID of the subnet (Avalanche L1) being delegated to. + */ + subnetId: string; + /** + * Optional. The number of signatures required to spend the funds in the + * resultant reward UTXO. + * + * @default 1 + */ + threshold?: number; + /** + * The amount being delegated in nAVAX. + */ + weight: bigint; +}>; + +/** + * Creates a new unsigned PVM add permissionless delegator transaction + * (`AddPermissionlessDelegatorTx`) using calculated dynamic fees. + * + * @param props {NewAddPermissionlessDelegatorTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newAddPermissionlessDelegatorTx: TxBuilderFn< + NewAddPermissionlessDelegatorTxProps +> = ( + { + end, + fromAddressesBytes, + locktime = 0n, + nodeId, + options, + rewardAddresses, + stakingAssetId, + start, + subnetId, + threshold = 1, + utxos, + weight, + }, + context, +) => { + const isPrimaryNetwork = subnetId === PrimaryNetworkID.toString(); + const fee = isPrimaryNetwork + ? context.addPrimaryNetworkDelegatorFee + : context.addSubnetDelegatorFee; + + const assetId = stakingAssetId ?? context.avaxAssetID; + + // Check if we use correct asset if on primary network + if (isPrimaryNetwork && assetId !== context.avaxAssetID) + throw new Error('Staking asset ID must be AVAX for the primary network.'); + + const toBurn = new Map([[context.avaxAssetID, fee]]); + const toStake = new Map([[assetId, weight]]); + + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const delegatorRewardsOwner = OutputOwners.fromNative( + rewardAddresses, + locktime, + threshold, + ); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const ownerComplexity = getOwnerComplexity(delegatorRewardsOwner); + + const complexity = addDimensions( + INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, + memoComplexity, + ownerComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn, + toStake, + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs, stakeOutputs } = spendResults; + + const delegatorTx = new AddPermissionlessDelegatorTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + SubnetValidator.fromNative( + nodeId, + start, + end, + weight, + Id.fromString(subnetId), + ), + stakeOutputs, + delegatorRewardsOwner, + ); + + return new UnsignedTx(delegatorTx, inputUTXOs, addressMaps); +}; + +export type NewTransferSubnetOwnershipTxProps = TxProps<{ + /** + * Optional. The number locktime field created in the resulting reward outputs. + * @default 0n + */ + locktime?: bigint; + /** + * Indices of existing subnet owners. + */ + subnetAuth: readonly number[]; + /** + * ID of the subnet (Avalanche L1). + */ + subnetId: string; + /** + * The new owner(s) addresses. + */ + subnetOwners: readonly Uint8Array[]; + /** + * Optional. The number of signatures required to spend the funds in the + * resultant reward UTXO. + * + * @default 1 + */ + threshold?: number; +}>; + +/** + * Creates a new unsigned PVM transfer subnet ownership transaction + * (`TransferSubnetOwnershipTx`) using calculated dynamic fees. + * + * @param props {NewTransferSubnetOwnershipTxProps} + * @param context {Context} + * @returns {UnsignedTx} An UnsignedTx. + */ +export const newTransferSubnetOwnershipTx: TxBuilderFn< + NewTransferSubnetOwnershipTxProps +> = ( + { + fromAddressesBytes, + locktime = 0n, + options, + subnetAuth, + subnetId, + subnetOwners, + threshold = 1, + utxos, + }, + context, +) => { + const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); + + const memoComplexity = getMemoComplexity(defaultedOptions); + + const authComplexity = getAuthComplexity(Input.fromNative(subnetAuth)); + + const ownerComplexity = getOwnerComplexity( + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ); + + const complexity = addDimensions( + INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, + memoComplexity, + authComplexity, + ownerComplexity, + ); + + const [error, spendResults] = spend({ + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), + utxos, + }); + + if (error) { + throw error; + } + + const { changeOutputs, inputs } = spendResults; + + return new UnsignedTx( + new TransferSubnetOwnershipTx( + AvaxBaseTx.fromNative( + context.networkID, + context.pBlockchainID, + changeOutputs, + inputs, + defaultedOptions.memo, + ), + Id.fromString(subnetId), + Input.fromNative(subnetAuth), + OutputOwners.fromNative(subnetOwners, locktime, threshold), + ), + inputUTXOs, + addressMaps, + ); +}; diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index e3f4b124b..1694fca98 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -77,7 +77,7 @@ import { * Returns the complexity outputs add to a transaction. */ export const getOutputComplexity = ( - transferableOutputs: TransferableOutput[], + transferableOutputs: readonly TransferableOutput[], ): Dimensions => { let complexity = createEmptyDimensions(); @@ -119,7 +119,7 @@ export const getOutputComplexity = ( * It includes the complexity that the corresponding credentials will add. */ export const getInputComplexity = ( - transferableInputs: TransferableInput[], + transferableInputs: readonly TransferableInput[], ): Dimensions => { let complexity = createEmptyDimensions(); diff --git a/src/vms/utils/calculateSpend/calculateSpend.ts b/src/vms/utils/calculateSpend/calculateSpend.ts index 3b90376e8..ff04b66ae 100644 --- a/src/vms/utils/calculateSpend/calculateSpend.ts +++ b/src/vms/utils/calculateSpend/calculateSpend.ts @@ -4,10 +4,6 @@ import type { Address } from '../../../serializable/fxs/common'; import { AddressMaps } from '../../../utils/addressMap'; import { compareTransferableOutputs } from '../../../utils/sort'; import type { SpendOptionsRequired } from '../../common'; -import { - createEmptyDimensions, - type Dimensions, -} from '../../common/fees/dimensions'; import type { UTXOCalculationFn, UTXOCalculationResult, @@ -64,14 +60,6 @@ export function calculateUTXOSpend( fromAddresses: Address[], options: SpendOptionsRequired, utxoCalculationFns: [UTXOCalculationFn, ...UTXOCalculationFn[]], - /** - * Complexity needed to calculate the fee for the transaction. - * - * Defaults to empty dimensions (ie 0). - * This means that either the tx type is unsupported, or the vm is unsupported. - * Essentially, if the complexity is empty, you should ignore it and calculate based on static fees. - */ - complexity: Dimensions = createEmptyDimensions(), ): UTXOCalculationResult { const startState: UTXOCalculationState = { amountsToBurn, @@ -79,7 +67,6 @@ export function calculateUTXOSpend( amountsToStake, fromAddresses, options, - complexity, ...defaultSpendResult(), }; const result = ( diff --git a/src/vms/utils/calculateSpend/models.ts b/src/vms/utils/calculateSpend/models.ts index 2acb8d136..510494a6f 100644 --- a/src/vms/utils/calculateSpend/models.ts +++ b/src/vms/utils/calculateSpend/models.ts @@ -6,7 +6,6 @@ import type { import type { Address } from '../../../serializable/fxs/common'; import type { SpendOptionsRequired } from '../../common'; import type { AddressMaps } from '../../../utils/addressMap'; -import type { Dimensions } from '../../common/fees/dimensions'; export interface UTXOCalculationResult { inputs: TransferableInput[]; @@ -22,7 +21,6 @@ export interface UTXOCalculationState extends UTXOCalculationResult { fromAddresses: Address[]; amountsToStake: Map; options: SpendOptionsRequired; - complexity: Dimensions; } export type UTXOCalculationFn = ( From f2fdad974b3f40d9d649a61db45684e312019c3b Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 21 Aug 2024 16:32:23 -0600 Subject: [PATCH 12/39] refactor: cleanup --- src/vms/pvm/builder.ts | 18 ------------------ src/vms/pvm/txs/fee/complexity.ts | 3 --- 2 files changed, 21 deletions(-) diff --git a/src/vms/pvm/builder.ts b/src/vms/pvm/builder.ts index 7b2176c6a..a962b14a3 100644 --- a/src/vms/pvm/builder.ts +++ b/src/vms/pvm/builder.ts @@ -140,24 +140,6 @@ export function newImportTx( throw new Error('no UTXOs available to import'); } - const outputs: TransferableOutput[] = []; - - for (const [assetID, amount] of Object.entries(importedAmounts)) { - if (assetID === context.avaxAssetID) { - continue; - } - - outputs.push( - TransferableOutput.fromNative( - assetID, - amount, - toAddresses, - locktime, - threshold, - ), - ); - } - let inputs: TransferableInput[] = []; let changeOutputs: TransferableOutput[] = []; diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 1694fca98..f65dd69b0 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -82,7 +82,6 @@ export const getOutputComplexity = ( let complexity = createEmptyDimensions(); for (const transferableOutput of transferableOutputs) { - // outputComplexity logic const outComplexity: Dimensions = { [FeeDimensions.Bandwidth]: INTRINSIC_OUTPUT_BANDWIDTH + INTRINSIC_SECP256K1_FX_OUTPUT_BANDWIDTH, @@ -106,7 +105,6 @@ export const getOutputComplexity = ( outComplexity[FeeDimensions.Bandwidth] += addressBandwidth; - // Finish with OutputComplexity logic complexity = addDimensions(complexity, outComplexity); } @@ -145,7 +143,6 @@ export const getInputComplexity = ( inputComplexity[FeeDimensions.Bandwidth] += signatureBandwidth; - // Finalize complexity = addDimensions(complexity, inputComplexity); } From 12dcdef31443184b133ea16ccd8eb6c4e175af83 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 28 Aug 2024 11:05:34 -0600 Subject: [PATCH 13/39] feat: add etna builder spend logic --- src/fixtures/context.ts | 5 + src/vms/common/unsignedTx.ts | 2 +- src/vms/context/context.ts | 5 + src/vms/context/model.ts | 6 + .../builder.ts} | 404 +++++++++-------- src/vms/pvm/etna-builder/spend.ts | 429 ++++++++++++++++++ src/vms/pvm/etna-builder/spendHelper.ts | 244 ++++++++++ 7 files changed, 912 insertions(+), 183 deletions(-) rename src/vms/pvm/{etnaBuilder.ts => etna-builder/builder.ts} (80%) create mode 100644 src/vms/pvm/etna-builder/spend.ts create mode 100644 src/vms/pvm/etna-builder/spendHelper.ts diff --git a/src/fixtures/context.ts b/src/fixtures/context.ts index 2e6aff484..0dc466304 100644 --- a/src/fixtures/context.ts +++ b/src/fixtures/context.ts @@ -1,3 +1,4 @@ +import { createDimensions } from '../vms/common/fees/dimensions'; import type { Context } from '../vms/context'; export const testContext: Context = { @@ -16,4 +17,8 @@ export const testContext: Context = { addSubnetDelegatorFee: 1000000n, networkID: 1, hrp: 'avax', + + // TODO: Adjust these based on what we want for the tests. + gasPrice: 1n, + complexityWeights: createDimensions(1, 1, 1, 1), }; diff --git a/src/vms/common/unsignedTx.ts b/src/vms/common/unsignedTx.ts index 4d68a2017..49be51240 100644 --- a/src/vms/common/unsignedTx.ts +++ b/src/vms/common/unsignedTx.ts @@ -24,7 +24,7 @@ export class UnsignedTx { credentials: Credential[]; constructor( readonly tx: Transaction, - readonly utxos: Utxo[], + readonly utxos: readonly Utxo[], readonly addressMaps: AddressMaps, credentials?: Credential[], ) { diff --git a/src/vms/context/context.ts b/src/vms/context/context.ts index b11dff3cc..a967b1c6b 100644 --- a/src/vms/context/context.ts +++ b/src/vms/context/context.ts @@ -1,6 +1,7 @@ import { getHRP } from '../../constants/networkIDs'; import { Info } from '../../info/info'; import { AVMApi } from '../avm/api'; +import { createEmptyDimensions } from '../common/fees/dimensions'; import type { Context } from './model'; /* @@ -48,5 +49,9 @@ export const getContextFromURI = async ( addSubnetDelegatorFee, networkID, hrp: getHRP(networkID), + + // TODO: Populate these values once they are exposed by the API + gasPrice: 0n, + complexityWeights: createEmptyDimensions(), }); }; diff --git a/src/vms/context/model.ts b/src/vms/context/model.ts index 1280a8a65..9f7498527 100644 --- a/src/vms/context/model.ts +++ b/src/vms/context/model.ts @@ -1,3 +1,5 @@ +import type { Dimensions } from '../common/fees/dimensions'; + export type Context = { readonly networkID: number; readonly hrp: string; @@ -14,4 +16,8 @@ export type Context = { readonly addPrimaryNetworkDelegatorFee: bigint; readonly addSubnetValidatorFee: bigint; readonly addSubnetDelegatorFee: bigint; + + // Post-etna + readonly gasPrice: bigint; + readonly complexityWeights: Dimensions; }; diff --git a/src/vms/pvm/etnaBuilder.ts b/src/vms/pvm/etna-builder/builder.ts similarity index 80% rename from src/vms/pvm/etnaBuilder.ts rename to src/vms/pvm/etna-builder/builder.ts index 0b4ca345f..1cc5741a5 100644 --- a/src/vms/pvm/etnaBuilder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -5,18 +5,20 @@ * PVM transactions post e-upgrade (etna), which uses dynamic fees based on transaction complexity. */ -import { PlatformChainID, PrimaryNetworkID } from '../../constants/networkIDs'; -import type { Address } from '../../serializable'; -import { Input, NodeId, OutputOwners, Stringpr } from '../../serializable'; +import { + PlatformChainID, + PrimaryNetworkID, +} from '../../../constants/networkIDs'; +import { Input, NodeId, OutputOwners, Stringpr } from '../../../serializable'; import { Bytes, Id, Int, TransferableInput, TransferableOutput, -} from '../../serializable'; -import { BaseTx as AvaxBaseTx } from '../../serializable/avax'; -import type { Utxo } from '../../serializable/avax/utxo'; +} from '../../../serializable'; +import { BaseTx as AvaxBaseTx } from '../../../serializable/avax'; +import type { Utxo } from '../../../serializable/avax/utxo'; import { AddPermissionlessDelegatorTx, AddPermissionlessValidatorTx, @@ -29,16 +31,16 @@ import { RemoveSubnetValidatorTx, SubnetValidator, TransferSubnetOwnershipTx, -} from '../../serializable/pvm'; -import { createSignerOrSignerEmptyFromStrings } from '../../serializable/pvm/signer'; -import { AddressMaps, addressesFromBytes } from '../../utils'; -import { getImportedInputsFromUtxos } from '../../utils/builderUtils'; -import { compareTransferableOutputs } from '../../utils/sort'; -import { baseTxUnsafePvm, type SpendOptions, UnsignedTx } from '../common'; -import { defaultSpendOptions } from '../common/defaultSpendOptions'; -import type { Dimensions } from '../common/fees/dimensions'; -import { addDimensions, createDimensions } from '../common/fees/dimensions'; -import type { Context } from '../context'; +} from '../../../serializable/pvm'; +import { createSignerOrSignerEmptyFromStrings } from '../../../serializable/pvm/signer'; +import { AddressMaps, addressesFromBytes } from '../../../utils'; +import { getImportedInputsFromUtxos } from '../../../utils/builderUtils'; +import { compareTransferableOutputs } from '../../../utils/sort'; +import { baseTxUnsafePvm, type SpendOptions, UnsignedTx } from '../../common'; +import { defaultSpendOptions } from '../../common/defaultSpendOptions'; +import type { Dimensions } from '../../common/fees/dimensions'; +import { addDimensions, createDimensions } from '../../common/fees/dimensions'; +import type { Context } from '../../context'; import { ID_LEN, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, @@ -56,72 +58,26 @@ import { getOutputComplexity, getOwnerComplexity, getSignerComplexity, -} from './txs/fee'; - -type SpendResult = Readonly<{ - changeOutputs: readonly TransferableOutput[]; +} from '../txs/fee'; +import { spend } from './spend'; + +const getAddressMaps = ({ + inputs, + inputUTXOs, + minIssuanceTime, + fromAddressesBytes, +}: { inputs: readonly TransferableInput[]; - stakeOutputs: readonly TransferableOutput[]; -}>; - -type SpendProps = Readonly<{ - /** - * Contains the currently accrued transaction complexity that - * will be used to calculate the required fees to be burned. - */ - complexity: Dimensions; - /** - * Contains the amount of extra AVAX that spend can produce in - * the change outputs in addition to the consumed and not burned AVAX. - */ - excessAVAX: bigint; - /** - * List of Addresses that are used for selecting which UTXOs are signable. - */ - fromAddresses: readonly Address[]; - /** - * Optionally specifies the output owners to use for the unlocked - * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined`, the default change owner is used. - */ - ownerOverride?: OutputOwners; - spendOptions: Required; - /** - * Maps `assetID` to the amount of the asset to spend without - * producing an output. This is typically used for fees. - * However, it can also be used to consume some of an asset that - * will be produced in separate outputs, such as ExportedOutputs. - * - * Only unlocked UTXOs are able to be burned here. - */ - toBurn: Map; - /** - * Maps `assetID` to the amount of the asset to spend and place info - * the staked outputs. First locked UTXOs are attempted to be used for - * these funds, and then unlocked UTXOs will be attempted to be used. - * There is no preferential ordering on the unlock times. - */ - toStake?: Map; - /** - * List of UTXOs that are available to be spent. - */ - utxos: readonly Utxo[]; -}>; - -// TODO: Move this to it's own file. -const spend = ({ - complexity, - excessAVAX, - fromAddresses, - ownerOverride, - spendOptions, - toBurn, - toStake, - utxos, -}: SpendProps): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - return [new Error('Not implemented'), null]; + inputUTXOs: readonly Utxo[]; + minIssuanceTime: bigint; + fromAddressesBytes: readonly Uint8Array[]; +}): AddressMaps => { + return AddressMaps.fromTransferableInputs( + inputs, + inputUTXOs, + minIssuanceTime, + fromAddressesBytes, + ); }; const getMemoComplexity = ( @@ -194,21 +150,30 @@ export const newBaseTx: TxBuilderFn = ( outputComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses, - spendOptions: defaultedOptions, - toBurn, - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const allOutputs = [...outputs, ...changeOutputs].sort( compareTransferableOutputs, @@ -328,15 +293,18 @@ export const newImportTx: TxBuilderFn = ( [context.avaxAssetID, context.baseTxFee - importedAvax], ]); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses, - spendOptions: defaultedOptions, - toBurn, - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }, + context, + ); if (error) { throw error; @@ -417,21 +385,30 @@ export const newExportTx: TxBuilderFn = ( outputComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses, - spendOptions: defaultedOptions, - toBurn, - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses, + spendOptions: defaultedOptions, + toBurn, + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); return new UnsignedTx( new ExportTx( @@ -487,21 +464,30 @@ export const newCreateSubnetTx: TxBuilderFn = ( ownerComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.createSubnetTxFee]]), - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.createSubnetTxFee]]), + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const createSubnetTx = new CreateSubnetTx( AvaxBaseTx.fromNative( @@ -591,21 +577,30 @@ export const newCreateChainTx: TxBuilderFn = ( authComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const createChainTx = new CreateChainTx( AvaxBaseTx.fromNative( @@ -677,21 +672,30 @@ export const newAddSubnetValidatorTx: TxBuilderFn< authComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const addSubnetValidatorTx = new AddSubnetValidatorTx( AvaxBaseTx.fromNative( @@ -752,21 +756,30 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< authComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const removeSubnetValidatorTx = new RemoveSubnetValidatorTx( AvaxBaseTx.fromNative( @@ -916,22 +929,31 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< delegatorOwnerComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn, - toStake, - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn, + toStake, + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs, stakeOutputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const validatorTx = new AddPermissionlessValidatorTx( AvaxBaseTx.fromNative( @@ -1061,22 +1083,31 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< ownerComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn, - toStake, - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn, + toStake, + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs, stakeOutputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); const delegatorTx = new AddPermissionlessDelegatorTx( AvaxBaseTx.fromNative( @@ -1167,21 +1198,30 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< ownerComplexity, ); - const [error, spendResults] = spend({ - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses: addressesFromBytes(fromAddressesBytes), - spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), - utxos, - }); + const [error, spendResults] = spend( + { + complexity, + // TODO: Check this + excessAVAX: 0n, + fromAddresses: addressesFromBytes(fromAddressesBytes), + spendOptions: defaultedOptions, + toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), + utxos, + }, + context, + ); if (error) { throw error; } - const { changeOutputs, inputs } = spendResults; + const { changeOutputs, inputs, inputUTXOs } = spendResults; + const addressMaps = getAddressMaps({ + inputs, + inputUTXOs, + minIssuanceTime: defaultedOptions.minIssuanceTime, + fromAddressesBytes, + }); return new UnsignedTx( new TransferSubnetOwnershipTx( diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts new file mode 100644 index 000000000..96cf2f698 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend.ts @@ -0,0 +1,429 @@ +import type { Address } from '../../../serializable'; +import { + BigIntPr, + Id, + OutputOwners, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { StakeableLockOut } from '../../../serializable/pvm'; +import { isStakeableLockOut, isTransferOut } from '../../../utils'; +import { matchOwners } from '../../../utils/matchOwners'; +import type { SpendOptions } from '../../common'; +import type { Dimensions } from '../../common/fees/dimensions'; +import type { Serializable } from '../../common/types'; +import type { Context } from '../../context'; +import { SpendHelper } from './spendHelper'; + +/** + * Separates the provided UTXOs into two lists: + * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. + * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. + * + * @param utxos {readonly Utxo[]} + * @param minIssuanceTime {bigint} + * + * @returns Object containing two lists of UTXOs. + */ +const splitByLocktime = ( + utxos: readonly Utxo[], + minIssuanceTime: bigint, +): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { + const locked: Utxo[] = []; + const unlocked: Utxo[] = []; + + for (const utxo of utxos) { + if (minIssuanceTime < utxo.getOutputOwners().locktime.value()) { + locked.push(utxo); + } else { + unlocked.push(utxo); + } + } + + return { locked, unlocked }; +}; + +/** + * Separates the provided UTXOs into two lists: + * - `other` contains UTXOs that have an asset ID different from `assetId`. + * - `requested` contains UTXOs that have an asset ID equal to `assetId`. + * + * @param utxos {readonly Utxo[]} + * @param assetId {string} + * + * @returns Object containing two lists of UTXOs. + */ +const splitByAssetId = ( + utxos: readonly Utxo[], + assetId: string, +): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { + const other: Utxo[] = []; + const requested: Utxo[] = []; + + for (const utxo of utxos) { + if (assetId === utxo.assetId.toString()) { + requested.push(utxo); + } else { + other.push(utxo); + } + } + + return { other, requested }; +}; + +/** + * Returns the TransferOutput that was, potentially, wrapped by a stakeable lockout. + * + * If the output was stakeable and locked, the locktime is returned. + * Otherwise, the locktime returned will be 0n. + * + * If the output is not an error is returned. + */ +const unwrapOutput = ( + output: Serializable, +): + | [error: null, transferOutput: TransferOutput, locktime: bigint] + | [error: Error, transferOutput: null, locktime: null] => { + if (isStakeableLockOut(output) && isTransferOut(output.transferOut)) { + return [null, output.transferOut, output.lockTime.value()]; + } else if (isTransferOut(output)) { + return [null, output, 0n]; + } + + return [new Error('Unknown output type'), null, null]; +}; + +type SpendResult = Readonly<{ + changeOutputs: readonly TransferableOutput[]; + inputs: readonly TransferableInput[]; + inputUTXOs: readonly Utxo[]; + stakeOutputs: readonly TransferableOutput[]; +}>; + +type SpendProps = Readonly<{ + /** + * Contains the currently accrued transaction complexity that + * will be used to calculate the required fees to be burned. + */ + complexity: Dimensions; + /** + * Contains the amount of extra AVAX that spend can produce in + * the change outputs in addition to the consumed and not burned AVAX. + */ + excessAVAX: bigint; + /** + * List of Addresses that are used for selecting which UTXOs are signable. + */ + fromAddresses: readonly Address[]; + /** + * Optionally specifies the output owners to use for the unlocked + * AVAX change output if no additional AVAX was needed to be burned. + * If this value is `undefined`, the default change owner is used. + */ + ownerOverride?: OutputOwners; + spendOptions: Required; + /** + * Maps `assetID` to the amount of the asset to spend without + * producing an output. This is typically used for fees. + * However, it can also be used to consume some of an asset that + * will be produced in separate outputs, such as ExportedOutputs. + * + * Only unlocked UTXOs are able to be burned here. + */ + toBurn: Map; + /** + * Maps `assetID` to the amount of the asset to spend and place info + * the staked outputs. First locked UTXOs are attempted to be used for + * these funds, and then unlocked UTXOs will be attempted to be used. + * There is no preferential ordering on the unlock times. + */ + toStake?: Map; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + complexity, + excessAVAX, + fromAddresses, + ownerOverride: _ownerOverride, + spendOptions, + toBurn, + toStake = new Map(), + utxos, + }: SpendProps, + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + let ownerOverride = + _ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + + const spendHelper = new SpendHelper({ + changeOutputs: [], + complexity, + gasPrice: context.gasPrice, + inputs: [], + stakeOutputs: [], + toBurn, + toStake: toStake ?? new Map(), + weights: context.complexityWeights, + }); + + const utxosByLocktime = splitByLocktime( + utxos, + spendOptions.minIssuanceTime, + ); + + for (const utxo of utxosByLocktime.locked) { + if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { + continue; + } + + // TODO: Maybe don't need this. + const [unwrapError, out, locktime] = unwrapOutput(utxo.output); + + if (unwrapError) { + return [unwrapError, null]; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + out.outputOwners, + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + spendHelper.addInput( + utxo, + // TODO: Verify this. + TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + ); + + const excess = spendHelper.consumeLockedAsset( + utxo.assetId.toString(), + out.amount(), + ); + + spendHelper.addStakedOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(locktime), + new TransferOutput( + new BigIntPr(out.amount() - excess), + out.outputOwners, + ), + ), + ), + ); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(locktime), + new TransferOutput(new BigIntPr(excess), out.outputOwners), + ), + ), + ); + } + + // Add all remaining stake amounts assuming unlocked UTXOs + for (const [assetId, amount] of toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + spendOptions.changeAddresses, + ), + ); + } + + // AVAX is handled last to account for fees. + const utxosByAVAXAssetId = splitByAssetId( + utxosByLocktime.unlocked, + context.avaxAssetID, + ); + + for (const utxo of utxosByAVAXAssetId.other) { + const assetId = utxo.assetId.toString(); + + if (!spendHelper.shouldConsumeAsset(assetId)) { + continue; + } + + const [unwrapError, out] = unwrapOutput(utxo.output); + + if (unwrapError) { + return [unwrapError, null]; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + out.outputOwners, + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + spendHelper.addInput( + utxo, + // TODO: Verify this. + TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + ); + + const excess = spendHelper.consumeAsset(assetId, out.amount()); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(excess), + OutputOwners.fromNative(spendOptions.changeAddresses), + ), + ), + ); + } + + let totalExcessAVAX = excessAVAX; + + for (const utxo of utxosByAVAXAssetId.requested) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const [error, out] = unwrapOutput(utxo.output); + + if (error) { + return [error, null]; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + out.outputOwners, + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + spendHelper.addInput( + utxo, + // TODO: Verify this. + TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + ); + + const excess = spendHelper.consumeAsset( + context.avaxAssetID, + out.amount(), + ); + totalExcessAVAX = excessAVAX + excess; + + // If we need to consume additional AVAX, we should be returning the + // change to the change address. + ownerOverride = OutputOwners.fromNative(spendOptions.changeAddresses); + } + + // Verify + const verifyError = spendHelper.verifyAssetsConsumed(); + if (verifyError) { + return [verifyError, null]; + } + + const requiredFee = spendHelper.calculateFee(); + + if (totalExcessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - totalExcessAVAX + } more nAVAX (${context.avaxAssetID})`, + ); + } + + // NOTE: This logic differs a bit from avalanche go because our classes are immutable. + spendHelper.addOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), ownerOverride), + ), + ); + + const requiredFeeWithChange = spendHelper.calculateFee(); + + if (totalExcessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(totalExcessAVAX - requiredFeeWithChange), + ownerOverride, + ), + ), + ); + } + + // Sorting happens in the .getInputsOutputs() method. + return [null, spendHelper.getInputsOutputs()]; + } catch (error) { + return [ + new Error('An unexpected error occurred during spend calculation', { + cause: error instanceof Error ? error : undefined, + }), + null, + ]; + } +}; diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts new file mode 100644 index 000000000..b573d4b1b --- /dev/null +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -0,0 +1,244 @@ +import type { TransferableOutput } from '../../../serializable'; +import { TransferableInput } from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { bigIntMin } from '../../../utils/bigintMath'; +import { compareTransferableOutputs } from '../../../utils/sort'; +import type { Dimensions } from '../../common/fees/dimensions'; +import { addDimensions, dimensionsToGas } from '../../common/fees/dimensions'; +import { getInputComplexity, getOutputComplexity } from '../txs/fee'; + +interface SpendHelperProps { + changeOutputs: readonly TransferableOutput[]; + complexity: Dimensions; + gasPrice: bigint; + inputs: readonly TransferableInput[]; + stakeOutputs: readonly TransferableOutput[]; + toBurn: Map; + toStake: Map; + weights: Dimensions; +} + +/** + * The SpendHelper class assists in managing and processing the spending of assets, + * including handling complexities, gas prices, and various outputs and inputs. + * + * @class + */ +export class SpendHelper { + private readonly gasPrice: bigint; + private readonly toBurn: Map; + private readonly toStake: Map; + private readonly weights: Dimensions; + + private complexity: Dimensions; + private changeOutputs: readonly TransferableOutput[]; + private inputs: readonly TransferableInput[]; + private stakeOutputs: readonly TransferableOutput[]; + + private inputUTXOs: readonly Utxo[] = []; + + constructor({ + changeOutputs, + complexity, + gasPrice, + inputs, + stakeOutputs, + toBurn, + toStake, + weights, + }: SpendHelperProps) { + this.gasPrice = gasPrice; + this.toBurn = toBurn; + this.toStake = toStake; + this.weights = weights; + + this.complexity = complexity; + this.changeOutputs = changeOutputs; + this.inputs = inputs; + this.stakeOutputs = stakeOutputs; + } + + /** + * Adds an input UTXO and its corresponding transferable input to the SpendHelper. + * + * @param {Utxo} utxo - The UTXO to be added. + * @param {TransferableInput} transferableInput - The transferable input corresponding to the UTXO. + * @returns {SpendHelper} The current instance of SpendHelper for chaining. + */ + addInput(utxo: Utxo, transferableInput: TransferableInput): SpendHelper { + const newInputComplexity = getInputComplexity([transferableInput]); + + this.inputs = [...this.inputs, transferableInput]; + this.complexity = addDimensions(this.complexity, newInputComplexity); + + this.inputUTXOs = [...this.inputUTXOs, utxo]; + + return this; + } + + /** + * Adds a change output to the SpendHelper. + * Change outputs are outputs that are sent back to the sender. + * + * @param {TransferableOutput} transferableOutput - The change output to be added. + * @returns {Dimensions} The complexity of the change output. + */ + addChangeOutput(transferableOutput: TransferableOutput): Dimensions { + this.changeOutputs = [...this.changeOutputs, transferableOutput]; + + return getOutputComplexity([transferableOutput]); + } + + /** + * Adds a staked output to the SpendHelper. + * Staked outputs are outputs that are staked by the sender. + * + * @param {TransferableOutput} transferableOutput - The staked output to be added. + * @returns {Dimensions} The complexity of the staked output. + */ + addStakedOutput(transferableOutput: TransferableOutput): Dimensions { + this.stakeOutputs = [...this.stakeOutputs, transferableOutput]; + + return getOutputComplexity([transferableOutput]); + } + + /** + * Adds a transferable output to the SpendHelper. + * + * @param {TransferableOutput} transferableOutput - The transferable output to be added. + * @returns {SpendHelper} The current instance of SpendHelper for chaining. + */ + addOutputComplexity(transferableOutput: TransferableOutput): SpendHelper { + const newOutputComplexity = getOutputComplexity([transferableOutput]); + + this.complexity = addDimensions(this.complexity, newOutputComplexity); + + return this; + } + + /** + * Determines if a locked asset should be consumed based on its asset ID. + * + * @param {string} assetId - The ID of the asset to check. + * @returns {boolean} - Returns true if the asset should be consumed, false otherwise. + */ + shouldConsumeLockedAsset(assetId: string): boolean { + return this.toStake.has(assetId) && this.toStake.get(assetId) !== 0n; + } + + /** + * Determines if an asset should be consumed based on its asset ID. + * + * @param {string} assetId - The ID of the asset to check. + * @returns {boolean} - Returns true if the asset should be consumed, false otherwise. + */ + shouldConsumeAsset(assetId: string): boolean { + return ( + (this.toBurn.has(assetId) && this.toBurn.get(assetId) !== 0n) || + this.shouldConsumeLockedAsset(assetId) + ); + } + + /** + * Consumes a locked asset based on its asset ID and amount. + * + * @param {string} assetId - The ID of the asset to consume. + * @param {bigint} amount - The amount of the asset to consume. + * @returns {bigint} The remaining amount of the asset after consumption. + */ + consumeLockedAsset(assetId: string, amount: bigint): bigint { + const assetToStake = this.toStake.get(assetId) ?? 0n; + + // Stake any value that should be staked + const toStake = bigIntMin(assetToStake, amount); + + this.toStake.set(assetId, assetToStake - toStake); + + return amount - toStake; + } + + /** + * Consumes an asset based on its asset ID and amount. + * + * @param {string} assetId - The ID of the asset to consume. + * @param {bigint} amount - The amount of the asset to consume. + * @returns {bigint} The remaining amount of the asset after consumption. + */ + consumeAsset(assetId: string, amount: bigint): bigint { + const assetToBurn = this.toBurn.get(assetId) ?? 0n; + + // Burn any value that should be burned + const toBurn = bigIntMin(assetToBurn, amount); + + this.toBurn.set(assetId, assetToBurn - toBurn); + + return this.consumeLockedAsset(assetId, amount - toBurn); + } + + /** + * Calculates the fee for the SpendHelper based on its complexity and gas price. + * + * @returns {bigint} The fee for the SpendHelper. + */ + calculateFee(): bigint { + const gas = dimensionsToGas(this.complexity, this.weights); + + return gas * this.gasPrice; + } + + /** + * Verifies that all assets have been consumed. + * + * @returns {Error | null} An error if any assets have not been consumed, null otherwise. + */ + verifyAssetsConsumed(): Error | null { + for (const [assetId, amount] of this.toStake) { + if (amount === 0n) { + continue; + } + + return new Error( + `Insufficient funds! Provided UTXOs need ${amount} more units of asset ${assetId} to stake`, + ); + } + + for (const [assetId, amount] of this.toBurn) { + if (amount === 0n) { + continue; + } + + return new Error( + `Insufficient funds! Provided UTXOs need ${amount} more units of asset ${assetId}`, + ); + } + + return null; + } + + /** + * Gets the inputs, outputs, and UTXOs for the SpendHelper. + * + * @returns {object} The inputs, outputs, and UTXOs for the SpendHelper + */ + getInputsOutputs(): { + changeOutputs: readonly TransferableOutput[]; + inputs: readonly TransferableInput[]; + inputUTXOs: readonly Utxo[]; + stakeOutputs: readonly TransferableOutput[]; + } { + const sortedInputs = [...this.inputs].sort(TransferableInput.compare); + const sortedChangeOutputs = [...this.changeOutputs].sort( + compareTransferableOutputs, + ); + const sortedStakeOutputs = [...this.stakeOutputs].sort( + compareTransferableOutputs, + ); + + return { + changeOutputs: sortedChangeOutputs, + inputs: sortedInputs, + inputUTXOs: this.inputUTXOs, + stakeOutputs: sortedStakeOutputs, + }; + } +} From fd46c2dbaa98e667d03c49e4df1c6be7924b8234 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 29 Aug 2024 09:23:59 -0600 Subject: [PATCH 14/39] test: etna builder spend unwrapOutput --- src/vms/pvm/etna-builder/index.ts | 1 + src/vms/pvm/etna-builder/spend.test.ts | 58 ++++++++++++++++++++++++++ src/vms/pvm/etna-builder/spend.ts | 30 +++++++++---- 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/vms/pvm/etna-builder/index.ts create mode 100644 src/vms/pvm/etna-builder/spend.test.ts diff --git a/src/vms/pvm/etna-builder/index.ts b/src/vms/pvm/etna-builder/index.ts new file mode 100644 index 000000000..ecea700bc --- /dev/null +++ b/src/vms/pvm/etna-builder/index.ts @@ -0,0 +1 @@ +export * from './builder'; diff --git a/src/vms/pvm/etna-builder/spend.test.ts b/src/vms/pvm/etna-builder/spend.test.ts new file mode 100644 index 000000000..948697d8b --- /dev/null +++ b/src/vms/pvm/etna-builder/spend.test.ts @@ -0,0 +1,58 @@ +import { + BigIntPr, + Int, + OutputOwners, + TransferOutput, +} from '../../../serializable'; +import { StakeableLockOut } from '../../../serializable/pvm'; +import type { Serializable } from '../../common/types'; +import { unwrapOutput } from './spend'; + +describe('./src/vms/pvm/etna-builder/spend.test.ts', () => { + describe('unwrapOutput', () => { + const normalOutput = new TransferOutput( + new BigIntPr(123n), + new OutputOwners(new BigIntPr(456n), new Int(1), []), + ); + + test.each([ + { + name: 'normal output', + testOutput: normalOutput, + expectedOutput: normalOutput, + expectedLocktime: 0n, + expectedError: null, + }, + { + name: 'locked output', + testOutput: new StakeableLockOut(new BigIntPr(789n), normalOutput), + expectedOutput: normalOutput, + expectedLocktime: 789n, + expectedError: null, + }, + { + name: 'locked output with no locktime', + testOutput: new StakeableLockOut(new BigIntPr(0n), normalOutput), + expectedOutput: normalOutput, + expectedLocktime: 0n, + expectedError: null, + }, + { + name: 'invalid output', + testOutput: null as unknown as Serializable, + expectedOutput: null, + expectedLocktime: null, + expectedError: expect.any(Error), + }, + ])( + `$name`, + ({ testOutput, expectedOutput, expectedLocktime, expectedError }) => { + const [error, output, locktime] = unwrapOutput(testOutput); + + expect(error).toEqual(expectedError); + expect(output).toEqual(expectedOutput); + expect(locktime).toEqual(expectedLocktime); + }, + ); + }); +}); diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 96cf2f698..8c6a86a93 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -18,6 +18,8 @@ import type { Context } from '../../context'; import { SpendHelper } from './spendHelper'; /** + * @internal + * * Separates the provided UTXOs into two lists: * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. @@ -27,7 +29,7 @@ import { SpendHelper } from './spendHelper'; * * @returns Object containing two lists of UTXOs. */ -const splitByLocktime = ( +export const splitByLocktime = ( utxos: readonly Utxo[], minIssuanceTime: bigint, ): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { @@ -46,6 +48,8 @@ const splitByLocktime = ( }; /** + * @internal + * * Separates the provided UTXOs into two lists: * - `other` contains UTXOs that have an asset ID different from `assetId`. * - `requested` contains UTXOs that have an asset ID equal to `assetId`. @@ -55,7 +59,7 @@ const splitByLocktime = ( * * @returns Object containing two lists of UTXOs. */ -const splitByAssetId = ( +export const splitByAssetId = ( utxos: readonly Utxo[], assetId: string, ): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { @@ -74,6 +78,8 @@ const splitByAssetId = ( }; /** + * @internal + * * Returns the TransferOutput that was, potentially, wrapped by a stakeable lockout. * * If the output was stakeable and locked, the locktime is returned. @@ -81,15 +87,25 @@ const splitByAssetId = ( * * If the output is not an error is returned. */ -const unwrapOutput = ( +export const unwrapOutput = ( output: Serializable, ): | [error: null, transferOutput: TransferOutput, locktime: bigint] | [error: Error, transferOutput: null, locktime: null] => { - if (isStakeableLockOut(output) && isTransferOut(output.transferOut)) { - return [null, output.transferOut, output.lockTime.value()]; - } else if (isTransferOut(output)) { - return [null, output, 0n]; + try { + if (isStakeableLockOut(output) && isTransferOut(output.transferOut)) { + return [null, output.transferOut, output.lockTime.value()]; + } else if (isTransferOut(output)) { + return [null, output, 0n]; + } + } catch (error) { + return [ + new Error('An unexpected error occurred while unwrapping output', { + cause: error instanceof Error ? error : undefined, + }), + null, + null, + ]; } return [new Error('Unknown output type'), null, null]; From 62cabd21e7f77aa64cf4a3d3d0368757065696a2 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 4 Sep 2024 17:06:26 -0600 Subject: [PATCH 15/39] feat: etna-builder fixes and more tests --- src/vms/common/defaultSpendOptions.ts | 5 +- src/vms/pvm/etna-builder/builder.test.ts | 960 +++++++++++++++++++++++ src/vms/pvm/etna-builder/builder.ts | 140 ++-- src/vms/pvm/etna-builder/spend.ts | 58 +- src/vms/pvm/etna-builder/spendHelper.ts | 34 +- 5 files changed, 1116 insertions(+), 81 deletions(-) create mode 100644 src/vms/pvm/etna-builder/builder.test.ts diff --git a/src/vms/common/defaultSpendOptions.ts b/src/vms/common/defaultSpendOptions.ts index 89e7d11b3..03e28d913 100644 --- a/src/vms/common/defaultSpendOptions.ts +++ b/src/vms/common/defaultSpendOptions.ts @@ -10,6 +10,9 @@ export const defaultSpendOptions = ( threshold: 1, memo: new Uint8Array(), locktime: 0n, - ...options, + // Only include options that are not undefined + ...Object.fromEntries( + Object.entries(options || {}).filter(([, v]) => v !== undefined), + ), }; }; diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts new file mode 100644 index 000000000..a889b5ac4 --- /dev/null +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -0,0 +1,960 @@ +import { testContext as _testContext } from '../../../fixtures/context'; +import { + getTransferableInputForTest, + getTransferableOutForTest, + getValidUtxo, + testAvaxAssetID, + testGenesisData, + testOwnerXAddress, + testSubnetId, + testUtxos, + testVMId, +} from '../../../fixtures/transactions'; +import { expectTxs } from '../../../fixtures/utils/expectTx'; +import { + BigIntPr, + Bytes, + Id, + Input, + Int, + NodeId, + OutputOwners, + Stringpr, + TransferableInput, + TransferableOutput, +} from '../../../serializable'; +import { + AddSubnetValidatorTx, + SubnetValidator, + type BaseTx as PVMBaseTx, + RemoveSubnetValidatorTx, + ImportTx, + ExportTx, + CreateSubnetTx, + CreateChainTx, + AddPermissionlessValidatorTx, + Signer, + TransferSubnetOwnershipTx, + AddPermissionlessDelegatorTx, +} from '../../../serializable/pvm'; +import { BaseTx as AvaxBaseTx } from '../../../serializable/avax'; +import { hexToBuffer } from '../../../utils'; +import type { UnsignedTx } from '../../common'; +import { createDimensions } from '../../common/fees/dimensions'; +import type { Context } from '../../context'; +import { calculateFee } from '../txs/fee/calculator'; +import { + newAddPermissionlessDelegatorTx, + newAddPermissionlessValidatorTx, + newAddSubnetValidatorTx, + newBaseTx, + newCreateChainTx, + newCreateSubnetTx, + newExportTx, + newImportTx, + newRemoveSubnetValidatorTx, + newTransferSubnetOwnershipTx, +} from './builder'; +import { testAddress1 } from '../../../fixtures/vms'; +import { AvaxToNAvax } from '../../../utils/avaxToNAvax'; +import { PrimaryNetworkID } from '../../../constants/networkIDs'; +import { + blsPublicKeyBytes, + blsSignatureBytes, +} from '../../../fixtures/primitives'; +import { proofOfPossession } from '../../../fixtures/pvm'; + +const testContext: Context = { + ..._testContext, + + // These are 0 in this context (dynamic fees). + // TODO: Should we assume the context of these will just be setting the values + // to 0n, or should we remove logic in the builder that bundles these static fees + // into the initialization of toBurn? + addPrimaryNetworkValidatorFee: 0n, + addPrimaryNetworkDelegatorFee: 0n, + addSubnetValidatorFee: 0n, + addSubnetDelegatorFee: 0n, + baseTxFee: 0n, + createAssetTxFee: 0n, + createSubnetTxFee: 0n, + createBlockchainTxFee: 0n, + transformSubnetTxFee: 0n, + + // Required context for post-Etna + gasPrice: 1n, + complexityWeights: createDimensions(1, 10, 100, 1000), +}; + +const addInputAmounts = ( + inputs: readonly TransferableInput[], +): Map => { + const consumed = new Map(); + + for (const input of inputs) { + const assetId = input.getAssetId(); + + consumed.set(assetId, (consumed.get(assetId) ?? 0n) + input.amount()); + } + + return consumed; +}; + +const addOutputAmounts = ( + outputs: readonly TransferableOutput[], +): Map => { + const produced = new Map(); + + for (const output of outputs) { + const assetId = output.getAssetId(); + + produced.set(assetId, (produced.get(assetId) ?? 0n) + output.amount()); + } + + return produced; +}; + +const addAmounts = (...amounts: Map[]): Map => { + const amount = new Map(); + + for (const m of amounts) { + for (const [assetID, value] of m) { + amount.set(assetID, (amount.get(assetID) ?? 0n) + value); + } + } + + return amount; +}; + +/** + * Given a bigint, returns a human-readable string of the value. + * + * @example + * ```ts + * formatBigIntToHumanReadable(123456789n); // '123_456_789n' + * formatBigIntToHumanReadable(1234567890n); // '1_234_567_890n' + * ``` + */ +const formatBigIntToHumanReadable = (value: bigint): string => { + const bigIntStr = value.toString(); + + return `${bigIntStr.replace(/\B(?=(\d{3})+(?!\d))/g, '_')}n`; +}; + +/** + * Calculates the required fee for the unsigned transaction + * and verifies that the burned amount is exactly the required fee. + */ +const checkFeeIsCorrect = ({ + unsignedTx, + inputs, + outputs, + additionalInputs = [], + additionalOutputs = [], +}: { + unsignedTx: UnsignedTx; + inputs: readonly TransferableInput[]; + outputs: readonly TransferableOutput[]; + additionalInputs?: readonly TransferableInput[]; + additionalOutputs?: readonly TransferableOutput[]; +}): [ + amountConsumed: Record, + expectedAmountConsumed: Record, + expectedFee: bigint, +] => { + const amountConsumed = addInputAmounts([...inputs, ...additionalInputs]); + const amountProduced = addOutputAmounts([...outputs, ...additionalOutputs]); + + const expectedFee = calculateFee( + unsignedTx.getTx(), + testContext.complexityWeights, + testContext.gasPrice, + ); + + const expectedAmountBurned = addAmounts( + new Map([[testAvaxAssetID.toString(), expectedFee]]), + ); + + const expectedAmountConsumed = addAmounts( + amountProduced, + expectedAmountBurned, + ); + + // Convert each map into a object with a stringified bigint value. + const safeExpectedAmountConsumed = Object.fromEntries( + [...expectedAmountConsumed].map(([k, v]) => [ + k, + formatBigIntToHumanReadable(v), + ]), + ); + + const safeAmountConsumed = Object.fromEntries( + [...amountConsumed].map(([k, v]) => [k, formatBigIntToHumanReadable(v)]), + ); + + return [safeAmountConsumed, safeExpectedAmountConsumed, expectedFee]; +}; + +describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { + const nodeId = 'NodeID-2m38qc95mhHXtrhjyGbe7r2NhniqHHJRB'; + const toAddress = hexToBuffer('0x5432112345123451234512'); + const fromAddressesBytes = [testOwnerXAddress.toBytes()]; + const getRewardsOwners = () => OutputOwners.fromNative([toAddress]); + + describe.each([ + { + name: 'no memo', + memo: undefined, + }, + { + name: 'with memo', + memo: Buffer.from('memo'), + }, + ])('$name', ({ memo }) => { + test('newBaseTx', () => { + const utxos = testUtxos(); + + const transferableOutput = TransferableOutput.fromNative( + testAvaxAssetID.toString(), + 1_000_000_000n, + [toAddress], + ); + + const utx = newBaseTx( + { + fromAddressesBytes, + outputs: [transferableOutput], + options: { + memo, + }, + utxos, + }, + testContext, + ); + + const { baseTx } = utx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(inputs.length).toEqual(1); + expect(outputs.length).toEqual(2); + + expect(outputs).toContain(transferableOutput); + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed] = checkFeeIsCorrect({ + unsignedTx: utx, + inputs, + outputs, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + }); + + test('newImportTx', () => { + const utxos = testUtxos(); + + const unsignedTx = newImportTx( + { + fromAddressesBytes, + options: { + memo, + }, + sourceChainId: testContext.cBlockchainID, + toAddresses: [testAddress1], + utxos, + }, + testContext, + ); + + const { baseTx, ins: importedIns } = unsignedTx.getTx() as ImportTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalInputs: importedIns, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new ImportTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [ + TransferableOutput.fromNative( + testContext.avaxAssetID, + // TODO: What is the expected value here? + 49_999_000_000n - expectedFee, + [testAddress1], + ), + ], + // TODO: Add an input here? + [], + memo ?? new Uint8Array(), + ), + Id.fromString(testContext.cBlockchainID), + [TransferableInput.fromUtxoAndSigindicies(utxos[2], [0])], + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newExportTx', () => { + const utxos = testUtxos(); + const tnsOut = TransferableOutput.fromNative( + testContext.avaxAssetID, + BigInt(5 * 1e9), + [toAddress], + ); + + const unsignedTx = newExportTx( + { + destinationChainId: testContext.cBlockchainID, + fromAddressesBytes, + options: { + memo, + }, + outputs: [tnsOut], + utxos, + }, + testContext, + ); + + const { baseTx, outs: exportedOuts } = unsignedTx.getTx() as ExportTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: exportedOuts, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new ExportTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [ + TransferableOutput.fromNative( + testContext.avaxAssetID, + // TODO: Possibly need to adjust this value? + 45_000_000_000n - expectedFee, + fromAddressesBytes, + ), + ], + [getTransferableInputForTest()], + memo ?? new Uint8Array(), + ), + Id.fromString(testContext.cBlockchainID), + [tnsOut], + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newCreateSubnetTx', () => { + const utxoInputAmt = BigInt(2 * 1e9); + + const unsignedTx = newCreateSubnetTx( + { + fromAddressesBytes, + options: { + memo, + }, + subnetOwners: [toAddress], + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + }, + testContext, + ); + + const { baseTx } = unsignedTx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ unsignedTx, inputs, outputs }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new CreateSubnetTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + getRewardsOwners(), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newCreateChainTx', () => { + const utxoInputAmt = BigInt(2 * 1e9); + + const unsignedTx = newCreateChainTx( + { + chainName: 'Random Chain Name', + fromAddressesBytes, + fxIds: [], + genesisData: testGenesisData, + options: { + memo, + }, + subnetAuth: [0], + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + vmId: Id.fromHex(testVMId).toString(), + }, + testContext, + ); + + const { baseTx } = unsignedTx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ unsignedTx, inputs, outputs }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new CreateChainTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + Id.fromHex(testSubnetId), + new Stringpr('Random Chain Name'), + Id.fromHex(testVMId), + [], + new Bytes(new TextEncoder().encode(JSON.stringify(testGenesisData))), + Input.fromNative([0]), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddSubnetValidatorTx', () => { + const utxoInputAmt = BigInt(2 * 1e9); + + const unsignedTx = newAddSubnetValidatorTx( + { + end: 190_000_000n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + subnetAuth: [0], + subnetId: Id.fromHex(testSubnetId).toString(), + start: 100n, + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + weight: 1_800_000n, + }, + testContext, + ); + + const { baseTx } = unsignedTx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ unsignedTx, inputs, outputs }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new AddSubnetValidatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + SubnetValidator.fromNative( + nodeId, + 100n, + 190_000_000n, + 1_800_000n, + Id.fromHex(testSubnetId), + ), + Input.fromNative([0]), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newRemoveSubnetValidatorTx', () => { + const utxoInputAmt = BigInt(2 * 1e9); + + const unsignedTx = newRemoveSubnetValidatorTx( + { + fromAddressesBytes, + nodeId, + options: { + memo, + }, + subnetAuth: [0], + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + }, + testContext, + ); + + const { baseTx } = unsignedTx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ unsignedTx, inputs, outputs }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new RemoveSubnetValidatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + NodeId.fromString(nodeId), + Id.fromHex(testSubnetId), + Input.fromNative([0]), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddPermissionlessValidatorTx - primary network', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakeAmount = 1_800_000n; + + const unsignedTx = newAddPermissionlessValidatorTx( + { + delegatorRewardsOwner: [], + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + publicKey: blsPublicKeyBytes(), + rewardAddresses: [], + shares: 1, + signature: blsSignatureBytes(), + start: 0n, + subnetId: PrimaryNetworkID.toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessValidatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new AddPermissionlessValidatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - stakeAmount - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + SubnetValidator.fromNative( + NodeId.fromString(nodeId).toString(), + 0n, + 120n, + stakeAmount, + PrimaryNetworkID, + ), + new Signer(proofOfPossession()), + [getTransferableOutForTest(stakeAmount)], //stake + OutputOwners.fromNative([], 0n, 1), + OutputOwners.fromNative([], 0n, 1), + new Int(1), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddPermissionlessValidatorTx - subnet', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakeAmount = 1_800_000n; + + const unsignedTx = newAddPermissionlessValidatorTx( + { + delegatorRewardsOwner: [], + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + publicKey: blsPublicKeyBytes(), + rewardAddresses: [], + shares: 1, + signature: blsSignatureBytes(), + start: 0n, + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessValidatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new AddPermissionlessValidatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - stakeAmount - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + SubnetValidator.fromNative( + NodeId.fromString(nodeId).toString(), + 0n, + 120n, + stakeAmount, + Id.fromHex(testSubnetId), + ), + new Signer(proofOfPossession()), + [getTransferableOutForTest(stakeAmount)], //stake + OutputOwners.fromNative([], 0n, 1), + OutputOwners.fromNative([], 0n, 1), + new Int(1), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddPermissionlessValidatorTx - subnet with non avax staking token', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakingAssetId = Id.fromHex('0102'); + const stakeAmount = 1_000_000n; + + const unsignedTx = newAddPermissionlessValidatorTx( + { + delegatorRewardsOwner: [], + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + publicKey: blsPublicKeyBytes(), + rewardAddresses: [], + shares: 1, + signature: blsSignatureBytes(), + stakingAssetId: stakingAssetId.toString(), + start: 0n, + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [ + getValidUtxo(new BigIntPr(utxoInputAmt)), + getValidUtxo(new BigIntPr(2n * stakeAmount), stakingAssetId), + ], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessValidatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(stake.length).toEqual(1); + // Expect correct stake out + expect(stake[0].assetId.toString()).toEqual(stakingAssetId.toString()); + expect(stake[0].amount()).toEqual(stakeAmount); + // Expect correct change utxos + expect(outputs.length).toEqual(2); + // Stake token change + expect(outputs[0].assetId.toString()).toEqual(stakingAssetId.toString()); + expect(outputs[0].amount()).toEqual(stakeAmount); + // AVAX Change + expect(outputs[1].assetId.toString()).toEqual(testContext.avaxAssetID); + expect(outputs[1].amount()).toEqual(utxoInputAmt - expectedFee); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + }); + + test('newAddPermissionlessDelegator - primary network', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakeAmount = 1_800_000n; + + const unsignedTx = newAddPermissionlessDelegatorTx( + { + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + rewardAddresses: [], + start: 0n, + subnetId: PrimaryNetworkID.toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessDelegatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new AddPermissionlessDelegatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - stakeAmount - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + SubnetValidator.fromNative( + NodeId.fromString(nodeId).toString(), + 0n, + 120n, + stakeAmount, + PrimaryNetworkID, + ), + [getTransferableOutForTest(stakeAmount)], //stake + OutputOwners.fromNative([], 0n, 1), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddPermissionlessDelegator - subnet', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakeAmount = 1_800_000n; + + const unsignedTx = newAddPermissionlessDelegatorTx( + { + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + rewardAddresses: [], + start: 0n, + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessDelegatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new AddPermissionlessDelegatorTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - stakeAmount - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + SubnetValidator.fromNative( + NodeId.fromString(nodeId).toString(), + 0n, + 120n, + stakeAmount, + Id.fromHex(testSubnetId), + ), + [getTransferableOutForTest(stakeAmount)], //stake + OutputOwners.fromNative([], 0n, 1), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + + test('newAddPermissionlessDelegator - subnet with non avax staking token', () => { + const utxoInputAmt = AvaxToNAvax(2); + const stakingAssetId = Id.fromHex('0102'); + const stakeAmount = 1_000_000n; + + const unsignedTx = newAddPermissionlessDelegatorTx( + { + end: 120n, + fromAddressesBytes, + nodeId, + options: { + memo, + }, + rewardAddresses: [], + stakingAssetId: stakingAssetId.toString(), + start: 0n, + subnetId: Id.fromHex(testSubnetId).toString(), + utxos: [ + getValidUtxo(new BigIntPr(utxoInputAmt)), + getValidUtxo(new BigIntPr(2n * stakeAmount), stakingAssetId), + ], + weight: stakeAmount, + }, + testContext, + ); + + const { baseTx, stake } = + unsignedTx.getTx() as AddPermissionlessDelegatorTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalOutputs: stake, + }); + + expect(stake.length).toEqual(1); + // Expect correct stake out + expect(stake[0].assetId.toString()).toEqual(stakingAssetId.toString()); + expect(stake[0].amount()).toEqual(stakeAmount); + // Expect correct change utxos + expect(outputs.length).toEqual(2); + // Stake token change + expect(outputs[0].assetId.toString()).toEqual(stakingAssetId.toString()); + expect(outputs[0].amount()).toEqual(stakeAmount); + // AVAX Change + expect(outputs[1].assetId.toString()).toEqual(testContext.avaxAssetID); + expect(outputs[1].amount()).toEqual(utxoInputAmt - expectedFee); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + }); + + test('newTransferSubnetOwnershipTx', () => { + const utxoInputAmt = BigInt(2 * 1e9); + const subnetAuth = [0, 1]; + + const unsignedTx = newTransferSubnetOwnershipTx( + { + fromAddressesBytes, + options: { + memo, + }, + subnetAuth, + subnetId: Id.fromHex(testSubnetId).toString(), + subnetOwners: [toAddress], + utxos: [getValidUtxo(new BigIntPr(utxoInputAmt))], + }, + testContext, + ); + + const { baseTx } = unsignedTx.getTx() as PVMBaseTx; + const { inputs, outputs, memo: txMemo } = baseTx; + + expect(txMemo.toString()).toEqual(memo ? 'memo' : ''); + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ unsignedTx, inputs, outputs }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new TransferSubnetOwnershipTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [getTransferableOutForTest(utxoInputAmt - expectedFee)], + [getTransferableInputForTest(utxoInputAmt)], + memo ?? new Uint8Array(), + ), + Id.fromHex(testSubnetId), + Input.fromNative(subnetAuth), + getRewardsOwners(), + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + }); +}); diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 1cc5741a5..84456ac70 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -9,7 +9,13 @@ import { PlatformChainID, PrimaryNetworkID, } from '../../../constants/networkIDs'; -import { Input, NodeId, OutputOwners, Stringpr } from '../../../serializable'; +import { + Input, + NodeId, + OutputOwners, + Stringpr, + TransferInput, +} from '../../../serializable'; import { Bytes, Id, @@ -33,8 +39,8 @@ import { TransferSubnetOwnershipTx, } from '../../../serializable/pvm'; import { createSignerOrSignerEmptyFromStrings } from '../../../serializable/pvm/signer'; -import { AddressMaps, addressesFromBytes } from '../../../utils'; -import { getImportedInputsFromUtxos } from '../../../utils/builderUtils'; +import { AddressMaps, addressesFromBytes, isTransferOut } from '../../../utils'; +import { matchOwners } from '../../../utils/matchOwners'; import { compareTransferableOutputs } from '../../../utils/sort'; import { baseTxUnsafePvm, type SpendOptions, UnsignedTx } from '../../common'; import { defaultSpendOptions } from '../../common/defaultSpendOptions'; @@ -137,7 +143,9 @@ export const newBaseTx: TxBuilderFn = ( outputs.forEach((out) => { const assetId = out.assetId.value(); - toBurn.set(assetId, (toBurn.get(assetId) || 0n) + out.output.amount()); + const amountToBurn = (toBurn.get(assetId) ?? 0n) + out.amount(); + + toBurn.set(assetId, amountToBurn); }); const memoComplexity = getMemoComplexity(defaultedOptions); @@ -192,7 +200,7 @@ export type NewImportTxProps = TxProps<{ /** * The locktime to write onto the UTXO. */ - locktime: bigint; + locktime?: bigint; /** * Base58 string of the source chain ID. */ @@ -200,7 +208,7 @@ export type NewImportTxProps = TxProps<{ /** * The threshold to write on the UTXO. */ - threshold: number; + threshold?: number; /** * List of addresses to import into. */ @@ -229,19 +237,46 @@ export const newImportTx: TxBuilderFn = ( const fromAddresses = addressesFromBytes(fromAddressesBytes); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - utxos = utxos.filter( - // Currently - only AVAX is allowed to be imported to the P-chain - (utxo) => utxo.assetId.toString() === context.avaxAssetID, - ); + const importedInputs: TransferableInput[] = []; + const importedAmounts: Record = {}; - const { importedAmounts, importedInputs, inputUTXOs } = - getImportedInputsFromUtxos( - utxos, - fromAddressesBytes, - defaultedOptions.minIssuanceTime, + for (const utxo of utxos) { + const out = utxo.output; + + if (!isTransferOut(out)) { + continue; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + fromAddresses, + // TODO: Verify this. + options?.minIssuanceTime ?? BigInt(Math.ceil(Date.now() / 1_000)), + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + importedInputs.push( + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + out.amt, + new Input(inputSigIndices.map((value) => new Int(value))), + ), + ), ); - const importedAvax = importedAmounts[context.avaxAssetID] ?? 0n; + const assetId = utxo.getAssetId(); + + importedAmounts[assetId] = (importedAmounts[assetId] ?? 0n) + out.amount(); + } + + const importedAvax = importedAmounts[context.avaxAssetID]; importedInputs.sort(TransferableInput.compare); const addressMaps = AddressMaps.fromTransferableInputs( @@ -250,7 +285,8 @@ export const newImportTx: TxBuilderFn = ( defaultedOptions.minIssuanceTime, fromAddressesBytes, ); - if (!importedInputs.length) { + + if (importedInputs.length === 0) { throw new Error('no UTXOs available to import'); } @@ -285,51 +321,47 @@ export const newImportTx: TxBuilderFn = ( outputComplexity, ); - let inputs: TransferableInput[] = []; - let changeOutputs: TransferableOutput[] = []; + const toBurn = new Map(); + let excessAVAX = 0n; - if (importedAvax < context.baseTxFee) { - const toBurn = new Map([ - [context.avaxAssetID, context.baseTxFee - importedAvax], - ]); + if (importedAvax && importedAvax < context.baseTxFee) { + toBurn.set(context.avaxAssetID, context.baseTxFee - importedAvax); + } else { + excessAVAX = importedAvax - context.baseTxFee; + } - const [error, spendResults] = spend( - { - complexity, - // TODO: Check this - excessAVAX: 0n, - fromAddresses, - spendOptions: defaultedOptions, - toBurn, - utxos, - }, - context, - ); + console.log('excessAVAX', excessAVAX); - if (error) { - throw error; - } + const [error, spendResults] = spend( + { + complexity, + excessAVAX, + fromAddresses, + ownerOverride: OutputOwners.fromNative(toAddresses, locktime, threshold), + spendOptions: defaultedOptions, + toBurn, + utxos, + }, + context, + ); - inputs = [...spendResults.inputs]; - changeOutputs = [...spendResults.changeOutputs]; - } else if (importedAvax > context.baseTxFee) { - changeOutputs.push( - TransferableOutput.fromNative( - context.avaxAssetID, - importedAvax - context.baseTxFee, - toAddresses, - locktime, - threshold, - ), - ); + if (error) { + throw error; } + const { changeOutputs, inputs, inputUTXOs } = spendResults; + + // NOTE: ChangeOutput amount should equal the excessAVAX amount. + console.log('changeOutputs', changeOutputs); + // NOTE: Inputs should be an empty array. + console.log('inputs', inputs); + return new UnsignedTx( new ImportTx( new AvaxBaseTx( new Int(context.networkID), PlatformChainID, - changeOutputs, + [...outputs, ...changeOutputs].sort(compareTransferableOutputs), inputs, new Bytes(defaultedOptions.memo), ), @@ -372,7 +404,7 @@ export const newExportTx: TxBuilderFn = ( outputs.forEach((output) => { const assetId = output.assetId.value(); - toBurn.set(assetId, (toBurn.get(assetId) || 0n) + output.output.amount()); + toBurn.set(assetId, (toBurn.get(assetId) ?? 0n) + output.output.amount()); }); const memoComplexity = getMemoComplexity(defaultedOptions); @@ -431,12 +463,12 @@ export type NewCreateSubnetTxProps = TxProps<{ /** * The locktime to write onto the UTXO. */ - locktime: bigint; + locktime?: bigint; subnetOwners: readonly Uint8Array[]; /** * The threshold to write on the UTXO. */ - threshold: number; + threshold?: number; }>; /** diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 8c6a86a93..8f43308d8 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -2,13 +2,15 @@ import type { Address } from '../../../serializable'; import { BigIntPr, Id, + Input, OutputOwners, + TransferInput, TransferOutput, TransferableInput, TransferableOutput, } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockOut } from '../../../serializable/pvm'; +import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; import { isStakeableLockOut, isTransferOut } from '../../../utils'; import { matchOwners } from '../../../utils/matchOwners'; import type { SpendOptions } from '../../common'; @@ -37,7 +39,18 @@ export const splitByLocktime = ( const unlocked: Utxo[] = []; for (const utxo of utxos) { - if (minIssuanceTime < utxo.getOutputOwners().locktime.value()) { + let utxoOwnersLocktime: bigint; + + try { + utxoOwnersLocktime = utxo.getOutputOwners().locktime.value(); + } catch (error) { + // If we can't get the locktime, we can't spend the UTXO. + // TODO: Is this the right thing to do? + // This was necessary to get tests working with testUtxos(). + continue; + } + + if (minIssuanceTime < utxoOwnersLocktime) { locked.push(utxo); } else { unlocked.push(utxo); @@ -174,7 +187,7 @@ type SpendProps = Readonly<{ export const spend = ( { complexity, - excessAVAX, + excessAVAX: _excessAVAX, fromAddresses, ownerOverride: _ownerOverride, spendOptions, @@ -189,6 +202,7 @@ export const spend = ( try { let ownerOverride = _ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + let excessAVAX: bigint = _excessAVAX; const spendHelper = new SpendHelper({ changeOutputs: [], @@ -211,7 +225,6 @@ export const spend = ( continue; } - // TODO: Maybe don't need this. const [unwrapError, out, locktime] = unwrapOutput(utxo.output); if (unwrapError) { @@ -233,7 +246,15 @@ export const spend = ( spendHelper.addInput( utxo, // TODO: Verify this. - TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + new BigIntPr(locktime), + new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + ), + ), ); const excess = spendHelper.consumeLockedAsset( @@ -321,7 +342,12 @@ export const spend = ( spendHelper.addInput( utxo, // TODO: Verify this. - TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + ), ); const excess = spendHelper.consumeAsset(assetId, out.amount()); @@ -343,8 +369,6 @@ export const spend = ( ); } - let totalExcessAVAX = excessAVAX; - for (const utxo of utxosByAVAXAssetId.requested) { const requiredFee = spendHelper.calculateFee(); @@ -379,14 +403,20 @@ export const spend = ( spendHelper.addInput( utxo, // TODO: Verify this. - TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + ), ); const excess = spendHelper.consumeAsset( context.avaxAssetID, out.amount(), ); - totalExcessAVAX = excessAVAX + excess; + + excessAVAX += excess; // If we need to consume additional AVAX, we should be returning the // change to the change address. @@ -401,10 +431,10 @@ export const spend = ( const requiredFee = spendHelper.calculateFee(); - if (totalExcessAVAX < requiredFee) { + if (excessAVAX < requiredFee) { throw new Error( `Insufficient funds: provided UTXOs need ${ - requiredFee - totalExcessAVAX + requiredFee - excessAVAX } more nAVAX (${context.avaxAssetID})`, ); } @@ -419,13 +449,13 @@ export const spend = ( const requiredFeeWithChange = spendHelper.calculateFee(); - if (totalExcessAVAX > requiredFeeWithChange) { + if (excessAVAX > requiredFeeWithChange) { // It is worth adding the change output. spendHelper.addChangeOutput( new TransferableOutput( Id.fromString(context.avaxAssetID), new TransferOutput( - new BigIntPr(totalExcessAVAX - requiredFeeWithChange), + new BigIntPr(excessAVAX - requiredFeeWithChange), ownerOverride, ), ), diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index b573d4b1b..5c1070a81 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -68,9 +68,9 @@ export class SpendHelper { addInput(utxo: Utxo, transferableInput: TransferableInput): SpendHelper { const newInputComplexity = getInputComplexity([transferableInput]); - this.inputs = [...this.inputs, transferableInput]; this.complexity = addDimensions(this.complexity, newInputComplexity); + this.inputs = [...this.inputs, transferableInput]; this.inputUTXOs = [...this.inputUTXOs, utxo]; return this; @@ -81,12 +81,12 @@ export class SpendHelper { * Change outputs are outputs that are sent back to the sender. * * @param {TransferableOutput} transferableOutput - The change output to be added. - * @returns {Dimensions} The complexity of the change output. + * @returns {SpendHelper} The current instance of SpendHelper for chaining. */ - addChangeOutput(transferableOutput: TransferableOutput): Dimensions { + addChangeOutput(transferableOutput: TransferableOutput): SpendHelper { this.changeOutputs = [...this.changeOutputs, transferableOutput]; - return getOutputComplexity([transferableOutput]); + return this.addOutputComplexity(transferableOutput); } /** @@ -94,12 +94,12 @@ export class SpendHelper { * Staked outputs are outputs that are staked by the sender. * * @param {TransferableOutput} transferableOutput - The staked output to be added. - * @returns {Dimensions} The complexity of the staked output. + * @returns {SpendHelper} The current instance of SpendHelper for chaining. */ - addStakedOutput(transferableOutput: TransferableOutput): Dimensions { + addStakedOutput(transferableOutput: TransferableOutput): SpendHelper { this.stakeOutputs = [...this.stakeOutputs, transferableOutput]; - return getOutputComplexity([transferableOutput]); + return this.addOutputComplexity(transferableOutput); } /** @@ -123,7 +123,7 @@ export class SpendHelper { * @returns {boolean} - Returns true if the asset should be consumed, false otherwise. */ shouldConsumeLockedAsset(assetId: string): boolean { - return this.toStake.has(assetId) && this.toStake.get(assetId) !== 0n; + return this.toStake.get(assetId) !== 0n; } /** @@ -134,8 +134,7 @@ export class SpendHelper { */ shouldConsumeAsset(assetId: string): boolean { return ( - (this.toBurn.has(assetId) && this.toBurn.get(assetId) !== 0n) || - this.shouldConsumeLockedAsset(assetId) + this.toBurn.get(assetId) !== 0n || this.shouldConsumeLockedAsset(assetId) ); } @@ -150,7 +149,12 @@ export class SpendHelper { const assetToStake = this.toStake.get(assetId) ?? 0n; // Stake any value that should be staked - const toStake = bigIntMin(assetToStake, amount); + const toStake = bigIntMin( + // Amount we still need to stake + assetToStake, + // Amount available to stake + amount, + ); this.toStake.set(assetId, assetToStake - toStake); @@ -168,10 +172,16 @@ export class SpendHelper { const assetToBurn = this.toBurn.get(assetId) ?? 0n; // Burn any value that should be burned - const toBurn = bigIntMin(assetToBurn, amount); + const toBurn = bigIntMin( + // Amount we still need to burn + assetToBurn, + // Amount available to burn + amount, + ); this.toBurn.set(assetId, assetToBurn - toBurn); + // Stake any remaining value that should be staked return this.consumeLockedAsset(assetId, amount - toBurn); } From 5e6ce5955c16d54e9f408538bc3dc5cbaba01111 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 4 Sep 2024 21:05:26 -0600 Subject: [PATCH 16/39] feat: etna builder wrap up bug fixes and tests --- src/vms/pvm/etna-builder/builder.test.ts | 7 +++---- src/vms/pvm/etna-builder/builder.ts | 16 ---------------- src/vms/pvm/etna-builder/spend.ts | 6 ++---- src/vms/pvm/etna-builder/spendHelper.ts | 5 +++-- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index a889b5ac4..9f052d4c2 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -289,12 +289,11 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { [ TransferableOutput.fromNative( testContext.avaxAssetID, - // TODO: What is the expected value here? - 49_999_000_000n - expectedFee, + // TODO: How to remove this "magic" number. How do we calculate it correctly from utxos? + 50_000_000_000n - expectedFee, [testAddress1], ), ], - // TODO: Add an input here? [], memo ?? new Uint8Array(), ), @@ -348,7 +347,7 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { [ TransferableOutput.fromNative( testContext.avaxAssetID, - // TODO: Possibly need to adjust this value? + // TODO: Remove magic number. How to calculate it correctly from utxos? 45_000_000_000n - expectedFee, fromAddressesBytes, ), diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 84456ac70..e60c7d01e 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -161,7 +161,6 @@ export const newBaseTx: TxBuilderFn = ( const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses, spendOptions: defaultedOptions, @@ -330,8 +329,6 @@ export const newImportTx: TxBuilderFn = ( excessAVAX = importedAvax - context.baseTxFee; } - console.log('excessAVAX', excessAVAX); - const [error, spendResults] = spend( { complexity, @@ -351,11 +348,6 @@ export const newImportTx: TxBuilderFn = ( const { changeOutputs, inputs, inputUTXOs } = spendResults; - // NOTE: ChangeOutput amount should equal the excessAVAX amount. - console.log('changeOutputs', changeOutputs); - // NOTE: Inputs should be an empty array. - console.log('inputs', inputs); - return new UnsignedTx( new ImportTx( new AvaxBaseTx( @@ -420,7 +412,6 @@ export const newExportTx: TxBuilderFn = ( const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses, spendOptions: defaultedOptions, @@ -499,7 +490,6 @@ export const newCreateSubnetTx: TxBuilderFn = ( const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -612,7 +602,6 @@ export const newCreateChainTx: TxBuilderFn = ( const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -707,7 +696,6 @@ export const newAddSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -791,7 +779,6 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -964,7 +951,6 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -1118,7 +1104,6 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, @@ -1233,7 +1218,6 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< const [error, spendResults] = spend( { complexity, - // TODO: Check this excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 8f43308d8..38534a711 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -141,7 +141,7 @@ type SpendProps = Readonly<{ * Contains the amount of extra AVAX that spend can produce in * the change outputs in addition to the consumed and not burned AVAX. */ - excessAVAX: bigint; + excessAVAX?: bigint; /** * List of Addresses that are used for selecting which UTXOs are signable. */ @@ -187,7 +187,7 @@ type SpendProps = Readonly<{ export const spend = ( { complexity, - excessAVAX: _excessAVAX, + excessAVAX: _excessAVAX = 0n, fromAddresses, ownerOverride: _ownerOverride, spendOptions, @@ -246,7 +246,6 @@ export const spend = ( spendHelper.addInput( utxo, // TODO: Verify this. - // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), new TransferableInput( utxo.utxoId, utxo.assetId, @@ -403,7 +402,6 @@ export const spend = ( spendHelper.addInput( utxo, // TODO: Verify this. - // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), new TransferableInput( utxo.utxoId, utxo.assetId, diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 5c1070a81..798e14584 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -123,7 +123,7 @@ export class SpendHelper { * @returns {boolean} - Returns true if the asset should be consumed, false otherwise. */ shouldConsumeLockedAsset(assetId: string): boolean { - return this.toStake.get(assetId) !== 0n; + return this.toStake.has(assetId) && this.toStake.get(assetId) !== 0n; } /** @@ -134,7 +134,8 @@ export class SpendHelper { */ shouldConsumeAsset(assetId: string): boolean { return ( - this.toBurn.get(assetId) !== 0n || this.shouldConsumeLockedAsset(assetId) + (this.toBurn.has(assetId) && this.toBurn.get(assetId) !== 0n) || + this.shouldConsumeLockedAsset(assetId) ); } From d4c5c0df8ccfba5f6492a19972ada0b27545146d Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 5 Sep 2024 08:45:09 -0600 Subject: [PATCH 17/39] feat: expose new etna builder via experimental exports --- src/vms/pvm/etna-builder/index.ts | 23 ++++++++++++++++++++++- src/vms/pvm/index.ts | 3 +++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/vms/pvm/etna-builder/index.ts b/src/vms/pvm/etna-builder/index.ts index ecea700bc..e10414a27 100644 --- a/src/vms/pvm/etna-builder/index.ts +++ b/src/vms/pvm/etna-builder/index.ts @@ -1 +1,22 @@ -export * from './builder'; +export { + type NewBaseTxProps, + newBaseTx as experimentalNewBaseTx, + type NewImportTxProps, + newImportTx as experimentalNewImportTx, + type NewExportTxProps, + newExportTx as experimentalNewExportTx, + type NewCreateSubnetTxProps, + newCreateSubnetTx as experimentalNewCreateSubnetTx, + type NewCreateChainTxProps, + newCreateChainTx as experimentalNewCreateChainTx, + type NewAddSubnetValidatorTxProps, + newAddSubnetValidatorTx as experimentalNewAddSubnetValidatorTx, + type NewRemoveSubnetValidatorTxProps, + newRemoveSubnetValidatorTx as experimentalNewRemoveSubnetValidatorTx, + type NewAddPermissionlessDelegatorTxProps, + newAddPermissionlessDelegatorTx as experimentalNewAddPermissionlessDelegatorTx, + type NewAddPermissionlessValidatorTxProps, + newAddPermissionlessValidatorTx as experimentalNewAddPermissionlessValidatorTx, + type NewTransferSubnetOwnershipTxProps, + newTransferSubnetOwnershipTx as experimentalNewTransferSubnetOwnershipTx, +} from './builder'; diff --git a/src/vms/pvm/index.ts b/src/vms/pvm/index.ts index b83ba1f78..584da912b 100644 --- a/src/vms/pvm/index.ts +++ b/src/vms/pvm/index.ts @@ -1,3 +1,6 @@ export * from './builder'; export * from './models'; export * from './api'; + +// Exposed Etna builder functions prefixed with 'experimental' +export * from './etna-builder'; From d85c261b22371759b9cfec9d245f973180a061c1 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 5 Sep 2024 09:18:03 -0600 Subject: [PATCH 18/39] refactor: cleanup code --- src/serializable/avax/transferableOutput.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/serializable/avax/transferableOutput.ts b/src/serializable/avax/transferableOutput.ts index 40ebd2f5c..d881d6680 100644 --- a/src/serializable/avax/transferableOutput.ts +++ b/src/serializable/avax/transferableOutput.ts @@ -49,11 +49,6 @@ export class TransferableOutput { return this.assetId.toString(); } - // TODO: Should we add this here? - getOutput() { - return this.output; - } - amount() { return this.output.amount(); } From c56dd6b5730c4415ceaf3722da408307d99f389b Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 6 Sep 2024 11:48:27 -0600 Subject: [PATCH 19/39] refactor: review comment adjustments and cleanup --- src/fixtures/transactions.ts | 9 +++++++- src/serializable/fxs/common/id.ts | 9 ++++++-- src/serializable/fxs/common/nodeId.ts | 10 +++++++-- src/serializable/primitives/bytes.ts | 1 - src/serializable/primitives/int.ts | 9 ++++++-- src/serializable/primitives/short.ts | 12 ++++++++-- src/vms/common/fees/dimensions.ts | 7 +----- src/vms/pvm/etna-builder/builder.ts | 2 +- src/vms/pvm/etna-builder/index.ts | 23 +------------------- src/vms/pvm/index.ts | 4 ++-- src/vms/pvm/txs/fee/calculator.test.ts | 14 +++--------- src/vms/pvm/txs/fee/complexity.test.ts | 18 ++++++--------- src/vms/pvm/txs/fee/complexity.ts | 11 ++++++++-- src/vms/pvm/txs/fee/constants.ts | 22 +++++++------------ src/vms/pvm/txs/fee/fixtures/transactions.ts | 5 +++++ src/vms/pvm/txs/fee/index.ts | 1 - 16 files changed, 77 insertions(+), 80 deletions(-) diff --git a/src/fixtures/transactions.ts b/src/fixtures/transactions.ts index 8d755e95f..bdbdf0f7f 100644 --- a/src/fixtures/transactions.ts +++ b/src/fixtures/transactions.ts @@ -14,9 +14,10 @@ import { } from '../serializable/fxs/secp256k1'; import { BigIntPr, Int, Bytes } from '../serializable/primitives'; import { StakeableLockIn, StakeableLockOut } from '../serializable/pvm'; -import { hexToBuffer } from '../utils'; +import { hexToBuffer, unpackWithManager } from '../utils'; import { testContext } from './context'; import { stringToBytes } from '@scure/base'; +import type { VM } from '../serializable'; export const cAddressForTest = '0xfd4DFC8f567caD8a275989982c5f8f1fC82B7563'; export const privateKeyForTest = @@ -190,3 +191,9 @@ export const getOutputForTest = () => new BigIntPr(BigInt(0.1 * 1e9)), Id.fromString(testContext.avaxAssetID), ); + +export const txHexToTransaction = (vm: VM, txHex: string) => { + const txBytes = hexToBuffer(txHex); + + return unpackWithManager(vm, txBytes); +}; diff --git a/src/serializable/fxs/common/id.ts b/src/serializable/fxs/common/id.ts index 5595ac16a..411f7e02d 100644 --- a/src/serializable/fxs/common/id.ts +++ b/src/serializable/fxs/common/id.ts @@ -6,6 +6,11 @@ import { serializable } from '../../common/types'; import { Primitives } from '../../primitives/primatives'; import { TypeSymbols } from '../../constants'; +/** + * Number of bytes per ID. + */ +export const ID_LEN = 32; + @serializable() export class Id extends Primitives { _type = TypeSymbols.Id; @@ -14,7 +19,7 @@ export class Id extends Primitives { } static fromBytes(buf: Uint8Array): [Id, Uint8Array] { - return [new Id(buf.slice(0, 32)), buf.slice(32)]; + return [new Id(buf.slice(0, ID_LEN)), buf.slice(ID_LEN)]; } static compare(id1: Id, id2: Id): number { @@ -26,7 +31,7 @@ export class Id extends Primitives { } toBytes() { - return padLeft(this.idVal, 32); + return padLeft(this.idVal, ID_LEN); } toJSON() { diff --git a/src/serializable/fxs/common/nodeId.ts b/src/serializable/fxs/common/nodeId.ts index 62542d86b..eabb79c25 100644 --- a/src/serializable/fxs/common/nodeId.ts +++ b/src/serializable/fxs/common/nodeId.ts @@ -6,6 +6,12 @@ import { Primitives } from '../../primitives/primatives'; import { TypeSymbols } from '../../constants'; export const NodeIDPrefix = 'NodeID-'; + +/** + * Number of bytes per NodeId. + */ +export const SHORT_ID_LEN = 20; + @serializable() export class NodeId extends Primitives { _type = TypeSymbols.NodeId; @@ -14,7 +20,7 @@ export class NodeId extends Primitives { } static fromBytes(buf: Uint8Array): [NodeId, Uint8Array] { - return [new NodeId(buf.slice(0, 20)), buf.slice(20)]; + return [new NodeId(buf.slice(0, SHORT_ID_LEN)), buf.slice(SHORT_ID_LEN)]; } [customInspectSymbol](_, options: any) { @@ -22,7 +28,7 @@ export class NodeId extends Primitives { } toBytes() { - return padLeft(this.idVal, 20); + return padLeft(this.idVal, SHORT_ID_LEN); } toJSON() { diff --git a/src/serializable/primitives/bytes.ts b/src/serializable/primitives/bytes.ts index 947bb82d0..f528acdd9 100644 --- a/src/serializable/primitives/bytes.ts +++ b/src/serializable/primitives/bytes.ts @@ -34,7 +34,6 @@ export class Bytes extends Primitives { return concatBytes(bytesForInt(this.bytes.length), this.bytes); } - // TODO: Is this okay or is there some other way of getting the length that is preferred? /** * Returns the length of the bytes (Uint8Array). * diff --git a/src/serializable/primitives/int.ts b/src/serializable/primitives/int.ts index 775630b40..2ff6f684d 100644 --- a/src/serializable/primitives/int.ts +++ b/src/serializable/primitives/int.ts @@ -4,6 +4,11 @@ import { serializable } from '../common/types'; import { Primitives } from './primatives'; import { TypeSymbols } from '../constants'; +/** + * Number of bytes per int. + */ +export const INT_LEN = 4; + @serializable() export class Int extends Primitives { _type = TypeSymbols.Int; @@ -12,7 +17,7 @@ export class Int extends Primitives { } static fromBytes(buf: Uint8Array): [Int, Uint8Array] { - return [new Int(bufferToNumber(buf.slice(0, 4))), buf.slice(4)]; + return [new Int(bufferToNumber(buf.slice(0, INT_LEN))), buf.slice(INT_LEN)]; } [customInspectSymbol]() { @@ -24,7 +29,7 @@ export class Int extends Primitives { } toBytes() { - return padLeft(hexToBuffer(this.int.toString(16)), 4); + return padLeft(hexToBuffer(this.int.toString(16)), INT_LEN); } value() { diff --git a/src/serializable/primitives/short.ts b/src/serializable/primitives/short.ts index 6662442e1..f7ff920ab 100644 --- a/src/serializable/primitives/short.ts +++ b/src/serializable/primitives/short.ts @@ -3,6 +3,11 @@ import { serializable } from '../common/types'; import { Primitives } from './primatives'; import { TypeSymbols } from '../constants'; +/** + * Number of bytes per short. + */ +export const SHORT_LEN = 2; + @serializable() export class Short extends Primitives { _type = TypeSymbols.Short; @@ -11,7 +16,10 @@ export class Short extends Primitives { } static fromBytes(buf: Uint8Array): [Short, Uint8Array] { - return [new Short(bufferToNumber(buf.slice(0, 2))), buf.slice(2)]; + return [ + new Short(bufferToNumber(buf.slice(0, SHORT_LEN))), + buf.slice(SHORT_LEN), + ]; } toJSON() { @@ -19,7 +27,7 @@ export class Short extends Primitives { } toBytes() { - return padLeft(hexToBuffer(this.short.toString(16)), 2); + return padLeft(hexToBuffer(this.short.toString(16)), SHORT_LEN); } value() { diff --git a/src/vms/common/fees/dimensions.ts b/src/vms/common/fees/dimensions.ts index 670d711af..30de5a857 100644 --- a/src/vms/common/fees/dimensions.ts +++ b/src/vms/common/fees/dimensions.ts @@ -34,12 +34,7 @@ export const createDimensions = ( * @returns The sum of the dimensions. */ export const addDimensions = (...dimensions: Dimensions[]): Dimensions => { - const result: Dimensions = { - [FeeDimensions.Bandwidth]: 0, - [FeeDimensions.DBRead]: 0, - [FeeDimensions.DBWrite]: 0, - [FeeDimensions.Compute]: 0, - }; + const result = createEmptyDimensions(); for (const dimension of dimensions) { result[FeeDimensions.Bandwidth] += dimension[FeeDimensions.Bandwidth]; result[FeeDimensions.DBRead] += dimension[FeeDimensions.DBRead]; diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index e60c7d01e..87f5de447 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -25,6 +25,7 @@ import { } from '../../../serializable'; import { BaseTx as AvaxBaseTx } from '../../../serializable/avax'; import type { Utxo } from '../../../serializable/avax/utxo'; +import { ID_LEN } from '../../../serializable/fxs/common/id'; import { AddPermissionlessDelegatorTx, AddPermissionlessValidatorTx, @@ -48,7 +49,6 @@ import type { Dimensions } from '../../common/fees/dimensions'; import { addDimensions, createDimensions } from '../../common/fees/dimensions'; import type { Context } from '../../context'; import { - ID_LEN, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, diff --git a/src/vms/pvm/etna-builder/index.ts b/src/vms/pvm/etna-builder/index.ts index e10414a27..ecea700bc 100644 --- a/src/vms/pvm/etna-builder/index.ts +++ b/src/vms/pvm/etna-builder/index.ts @@ -1,22 +1 @@ -export { - type NewBaseTxProps, - newBaseTx as experimentalNewBaseTx, - type NewImportTxProps, - newImportTx as experimentalNewImportTx, - type NewExportTxProps, - newExportTx as experimentalNewExportTx, - type NewCreateSubnetTxProps, - newCreateSubnetTx as experimentalNewCreateSubnetTx, - type NewCreateChainTxProps, - newCreateChainTx as experimentalNewCreateChainTx, - type NewAddSubnetValidatorTxProps, - newAddSubnetValidatorTx as experimentalNewAddSubnetValidatorTx, - type NewRemoveSubnetValidatorTxProps, - newRemoveSubnetValidatorTx as experimentalNewRemoveSubnetValidatorTx, - type NewAddPermissionlessDelegatorTxProps, - newAddPermissionlessDelegatorTx as experimentalNewAddPermissionlessDelegatorTx, - type NewAddPermissionlessValidatorTxProps, - newAddPermissionlessValidatorTx as experimentalNewAddPermissionlessValidatorTx, - type NewTransferSubnetOwnershipTxProps, - newTransferSubnetOwnershipTx as experimentalNewTransferSubnetOwnershipTx, -} from './builder'; +export * from './builder'; diff --git a/src/vms/pvm/index.ts b/src/vms/pvm/index.ts index 584da912b..063e2d988 100644 --- a/src/vms/pvm/index.ts +++ b/src/vms/pvm/index.ts @@ -2,5 +2,5 @@ export * from './builder'; export * from './models'; export * from './api'; -// Exposed Etna builder functions prefixed with 'experimental' -export * from './etna-builder'; +// Exposed Etna builder functions under `e` namespace +export * as e from './etna-builder'; diff --git a/src/vms/pvm/txs/fee/calculator.test.ts b/src/vms/pvm/txs/fee/calculator.test.ts index 6b629f377..14af4378b 100644 --- a/src/vms/pvm/txs/fee/calculator.test.ts +++ b/src/vms/pvm/txs/fee/calculator.test.ts @@ -1,4 +1,4 @@ -import { hexToBuffer, unpackWithManager } from '../../../../utils'; +import { txHexToTransaction } from '../../../../fixtures/transactions'; import { calculateFee } from './calculator'; import { TEST_DYNAMIC_PRICE, @@ -7,21 +7,13 @@ import { TEST_UNSUPPORTED_TRANSACTIONS, } from './fixtures/transactions'; -const txHexToPVMTransaction = (txHex: string) => { - const txBytes = hexToBuffer(txHex); - - // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); - - return unpackWithManager('PVM', txBytes); -}; - describe('Calculator', () => { describe('calculateFee', () => { test.each(TEST_TRANSACTIONS)( 'calculates the fee for $name', ({ txHex, expectedDynamicFee }) => { const result = calculateFee( - txHexToPVMTransaction(txHex), + txHexToTransaction('PVM', txHex), TEST_DYNAMIC_WEIGHTS, TEST_DYNAMIC_PRICE, ); @@ -33,7 +25,7 @@ describe('Calculator', () => { test.each(TEST_UNSUPPORTED_TRANSACTIONS)( 'unsupported tx - $name', ({ txHex }) => { - const tx = txHexToPVMTransaction(txHex); + const tx = txHexToTransaction('PVM', txHex); expect(() => { calculateFee(tx, TEST_DYNAMIC_WEIGHTS, TEST_DYNAMIC_PRICE); diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index c1a140367..7f56fe163 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -2,6 +2,7 @@ import { utxoId } from '../../../../fixtures/avax'; import { address, id } from '../../../../fixtures/common'; import { bigIntPr, int, ints } from '../../../../fixtures/primitives'; import { signer } from '../../../../fixtures/pvm'; +import { txHexToTransaction } from '../../../../fixtures/transactions'; import { Input, OutputOwners, @@ -15,7 +16,6 @@ import { StakeableLockIn, StakeableLockOut, } from '../../../../serializable/pvm'; -import { hexToBuffer, unpackWithManager } from '../../../../utils'; import { createDimensions } from '../../../common/fees/dimensions'; import { getAuthComplexity, @@ -53,14 +53,10 @@ const makeTransferableInput = (numOfSigInts = 0) => ), ); -const txHexToPVMTransaction = (txHex: string) => { - const txBytes = hexToBuffer(txHex); - - // console.log('txBytes length:', txBytes.length, '=== expected bandwidth'); - - return unpackWithManager('PVM', txBytes); -}; - +/** + * These tests are based off the tests found in the AvalancheGo repository: + * @see https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/txs/fee/complexity_test.go + */ describe('Complexity', () => { describe('getOutputComplexity', () => { test('empty transferable output', () => { @@ -258,7 +254,7 @@ describe('Complexity', () => { describe('getTxComplexity', () => { test.each(TEST_TRANSACTIONS)('$name', ({ txHex, expectedComplexity }) => { - const tx = txHexToPVMTransaction(txHex); + const tx = txHexToTransaction('PVM', txHex); const result = getTxComplexity(tx); @@ -268,7 +264,7 @@ describe('Complexity', () => { test.each(TEST_UNSUPPORTED_TRANSACTIONS)( 'unsupported tx - $name', ({ txHex }) => { - const tx = txHexToPVMTransaction(txHex); + const tx = txHexToTransaction('PVM', txHex); expect(() => { getTxComplexity(tx); diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index f65dd69b0..4d5891d63 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -1,5 +1,14 @@ +/** + * @module + * + * The functions in this module are based off the complexity calculations found in the AvalancheGo repository. + * @see https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/txs/fee/complexity.go + */ + import type { OutputOwners } from '../../../../serializable'; import { Input } from '../../../../serializable/fxs/secp256k1'; +import { SHORT_ID_LEN } from '../../../../serializable/fxs/common/nodeId'; +import { ID_LEN } from '../../../../serializable/fxs/common/id'; import { type BaseTx, type TransferableInput, @@ -46,7 +55,6 @@ import { import type { Serializable } from '../../../common/types'; import type { Transaction } from '../../../common'; import { - ID_LEN, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, @@ -70,7 +78,6 @@ import { INTRINSIC_STAKEABLE_LOCKED_INPUT_BANDWIDTH, INTRINSIC_STAKEABLE_LOCKED_OUTPUT_BANDWIDTH, INTRINSIC_TRANSFER_SUBNET_OWNERSHIP_TX_COMPLEXITIES, - SHORT_ID_LEN, } from './constants'; /** diff --git a/src/vms/pvm/txs/fee/constants.ts b/src/vms/pvm/txs/fee/constants.ts index 0de22dc83..c8d5532e5 100644 --- a/src/vms/pvm/txs/fee/constants.ts +++ b/src/vms/pvm/txs/fee/constants.ts @@ -1,3 +1,7 @@ +/** + * The INTRINSIC constants are based on the following constants from the AvalancheGo codebase: + * @see https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/txs/fee/complexity.go + */ import type { Dimensions } from '../../../common/fees/dimensions'; import { FeeDimensions } from '../../../common/fees/dimensions'; import { @@ -5,26 +9,16 @@ import { SIGNATURE_LENGTH as BLS_SIGNATURE_LENGTH, } from '../../../../crypto/bls'; import { SIGNATURE_LENGTH } from '../../../../crypto/secp256k1'; +import { INT_LEN } from '../../../../serializable/primitives/int'; +import { SHORT_LEN } from '../../../../serializable/primitives/short'; +import { SHORT_ID_LEN } from '../../../../serializable/fxs/common/nodeId'; +import { ID_LEN } from '../../../../serializable/fxs/common/id'; /** * Number of bytes per long. */ const LONG_LEN = 8; -export const ID_LEN = 32; - -/** - * Number of bytes per short. - */ -const SHORT_LEN = 2; - -export const SHORT_ID_LEN = 20; - -/** - * Number of bytes per int. - */ -const INT_LEN = 4; - const INTRINSIC_VALIDATOR_BANDWIDTH = SHORT_ID_LEN + // Node ID (Short ID = 20) LONG_LEN + // Start diff --git a/src/vms/pvm/txs/fee/fixtures/transactions.ts b/src/vms/pvm/txs/fee/fixtures/transactions.ts index c049a9744..838fed08c 100644 --- a/src/vms/pvm/txs/fee/fixtures/transactions.ts +++ b/src/vms/pvm/txs/fee/fixtures/transactions.ts @@ -1,3 +1,8 @@ +/** + * These test transactions are based off of AvalancheGo's test transactions. + * @see https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/txs/fee/calculator_test.go + */ + import type { Dimensions } from '../../../../common/fees/dimensions'; import { FeeDimensions, diff --git a/src/vms/pvm/txs/fee/index.ts b/src/vms/pvm/txs/fee/index.ts index 5ee1daa0d..41e6534c3 100644 --- a/src/vms/pvm/txs/fee/index.ts +++ b/src/vms/pvm/txs/fee/index.ts @@ -1,5 +1,4 @@ export { - ID_LEN, INTRINSIC_ADD_PERMISSIONLESS_DELEGATOR_TX_COMPLEXITIES, INTRINSIC_ADD_PERMISSIONLESS_VALIDATOR_TX_COMPLEXITIES, INTRINSIC_ADD_SUBNET_VALIDATOR_TX_COMPLEXITIES, From 35c2109080e18cf0c2a73437e5cc38b0d105a60c Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Mon, 9 Sep 2024 09:02:17 -0600 Subject: [PATCH 20/39] refactor: review comments and simplify builder in spots --- src/vms/pvm/etna-builder/builder.ts | 43 +++++++---------------------- src/vms/pvm/etna-builder/spend.ts | 4 +-- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 87f5de447..fff2d1157 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -137,9 +137,7 @@ export const newBaseTx: TxBuilderFn = ( [...fromAddressesBytes], options, ); - const toBurn = new Map([ - [context.avaxAssetID, context.baseTxFee], - ]); + const toBurn = new Map(); outputs.forEach((out) => { const assetId = out.assetId.value(); @@ -250,8 +248,7 @@ export const newImportTx: TxBuilderFn = ( matchOwners( utxo.getOutputOwners(), fromAddresses, - // TODO: Verify this. - options?.minIssuanceTime ?? BigInt(Math.ceil(Date.now() / 1_000)), + defaultedOptions.minIssuanceTime, ) || {}; if (inputSigIndices === undefined) { @@ -275,6 +272,10 @@ export const newImportTx: TxBuilderFn = ( importedAmounts[assetId] = (importedAmounts[assetId] ?? 0n) + out.amount(); } + if (importedInputs.length === 0) { + throw new Error('no UTXOs available to import'); + } + const importedAvax = importedAmounts[context.avaxAssetID]; importedInputs.sort(TransferableInput.compare); @@ -285,10 +286,6 @@ export const newImportTx: TxBuilderFn = ( fromAddressesBytes, ); - if (importedInputs.length === 0) { - throw new Error('no UTXOs available to import'); - } - const outputs: TransferableOutput[] = []; for (const [assetID, amount] of Object.entries(importedAmounts)) { @@ -321,18 +318,11 @@ export const newImportTx: TxBuilderFn = ( ); const toBurn = new Map(); - let excessAVAX = 0n; - - if (importedAvax && importedAvax < context.baseTxFee) { - toBurn.set(context.avaxAssetID, context.baseTxFee - importedAvax); - } else { - excessAVAX = importedAvax - context.baseTxFee; - } const [error, spendResults] = spend( { complexity, - excessAVAX, + excessAVAX: importedAvax, fromAddresses, ownerOverride: OutputOwners.fromNative(toAddresses, locktime, threshold), spendOptions: defaultedOptions, @@ -390,9 +380,7 @@ export const newExportTx: TxBuilderFn = ( const fromAddresses = addressesFromBytes(fromAddressesBytes); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const toBurn = new Map([ - [context.avaxAssetID, context.baseTxFee], - ]); + const toBurn = new Map(); outputs.forEach((output) => { const assetId = output.assetId.value(); @@ -493,7 +481,6 @@ export const newCreateSubnetTx: TxBuilderFn = ( excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.createSubnetTxFee]]), utxos, }, context, @@ -605,7 +592,6 @@ export const newCreateChainTx: TxBuilderFn = ( excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.createBlockchainTxFee]]), utxos, }, context, @@ -699,7 +685,6 @@ export const newAddSubnetValidatorTx: TxBuilderFn< excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.addSubnetValidatorFee]]), utxos, }, context, @@ -782,7 +767,6 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), utxos, }, context, @@ -908,10 +892,7 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< context, ) => { const isPrimaryNetwork = subnetId === PrimaryNetworkID.toString(); - const fee = isPrimaryNetwork - ? context.addPrimaryNetworkValidatorFee - : context.addSubnetValidatorFee; - const toBurn = new Map([[context.avaxAssetID, fee]]); + const toBurn = new Map(); const assetId = stakingAssetId ?? context.avaxAssetID; @@ -1070,9 +1051,6 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< context, ) => { const isPrimaryNetwork = subnetId === PrimaryNetworkID.toString(); - const fee = isPrimaryNetwork - ? context.addPrimaryNetworkDelegatorFee - : context.addSubnetDelegatorFee; const assetId = stakingAssetId ?? context.avaxAssetID; @@ -1080,7 +1058,7 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< if (isPrimaryNetwork && assetId !== context.avaxAssetID) throw new Error('Staking asset ID must be AVAX for the primary network.'); - const toBurn = new Map([[context.avaxAssetID, fee]]); + const toBurn = new Map(); const toStake = new Map([[assetId, weight]]); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); @@ -1221,7 +1199,6 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn: new Map([[context.avaxAssetID, context.baseTxFee]]), utxos, }, context, diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 38534a711..baedb220c 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -161,7 +161,7 @@ type SpendProps = Readonly<{ * * Only unlocked UTXOs are able to be burned here. */ - toBurn: Map; + toBurn?: Map; /** * Maps `assetID` to the amount of the asset to spend and place info * the staked outputs. First locked UTXOs are attempted to be used for @@ -191,7 +191,7 @@ export const spend = ( fromAddresses, ownerOverride: _ownerOverride, spendOptions, - toBurn, + toBurn = new Map(), toStake = new Map(), utxos, }: SpendProps, From c18f9babe68b298d6b2ae1b4273703daf2fa86d4 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Tue, 10 Sep 2024 09:43:29 -0600 Subject: [PATCH 21/39] test: coverage of spendHelper --- src/vms/pvm/etna-builder/spendHelper.test.ts | 331 +++++++++++++++++++ src/vms/pvm/etna-builder/spendHelper.ts | 10 +- 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 src/vms/pvm/etna-builder/spendHelper.test.ts diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts new file mode 100644 index 000000000..7d9d3df0d --- /dev/null +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -0,0 +1,331 @@ +import { + transferableInput, + transferableOutput, + utxo, +} from '../../../fixtures/avax'; +import { createDimensions } from '../../common/fees/dimensions'; +import type { SpendHelperProps } from './spendHelper'; +import { SpendHelper } from './spendHelper'; + +const DEFAULT_GAS_PRICE = 3n; + +const DEFAULT_WEIGHTS = createDimensions(1, 2, 3, 4); + +const DEFAULT_PROPS: SpendHelperProps = { + changeOutputs: [], + complexity: createDimensions(1, 1, 1, 1), + gasPrice: DEFAULT_GAS_PRICE, + inputs: [], + stakeOutputs: [], + toBurn: new Map(), + toStake: new Map(), + weights: DEFAULT_WEIGHTS, +}; + +describe('src/vms/pvm/etna-builder/spendHelper', () => { + describe('SpendHelper', () => { + test('initialized with correct values', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(spendHelper).toBeInstanceOf(SpendHelper); + + const results = spendHelper.getInputsOutputs(); + + expect(results.changeOutputs).toEqual([]); + expect(results.inputs).toEqual([]); + expect(results.inputUTXOs).toEqual([]); + expect(results.stakeOutputs).toEqual([]); + }); + }); + + test('adding inputs and outputs', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(spendHelper.calculateFee()).toBe(30n); + expect(spendHelper.getInputsOutputs()).toEqual({ + changeOutputs: [], + inputs: [], + inputUTXOs: [], + stakeOutputs: [], + }); + + spendHelper.addOutputComplexity(transferableOutput()); + + expect(spendHelper.calculateFee()).toBe(339n); + + const inputUtxo = utxo(); + const inputTransferableInput = transferableInput(); + + spendHelper.addInput(inputUtxo, inputTransferableInput); + + expect(spendHelper.calculateFee()).toBe(1251n); + expect(spendHelper.getInputsOutputs()).toEqual({ + changeOutputs: [], + inputs: [inputTransferableInput], + inputUTXOs: [inputUtxo], + stakeOutputs: [], + }); + + const changeOutput = transferableOutput(); + + spendHelper.addChangeOutput(changeOutput); + + expect(spendHelper.calculateFee()).toBe(1560n); + expect(spendHelper.getInputsOutputs()).toEqual({ + changeOutputs: [changeOutput], + inputs: [inputTransferableInput], + inputUTXOs: [inputUtxo], + stakeOutputs: [], + }); + + const stakeOutput = transferableOutput(); + + spendHelper.addStakedOutput(stakeOutput); + + expect(spendHelper.calculateFee()).toBe(1869n); + expect(spendHelper.getInputsOutputs()).toEqual({ + changeOutputs: [changeOutput], + inputs: [inputTransferableInput], + inputUTXOs: [inputUtxo], + stakeOutputs: [stakeOutput], + }); + }); + + describe('SpendHelper.shouldConsumeLockedAsset', () => { + test('returns false for asset not in toStake', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(false); + }); + + test('returns false for asset in toStake with 0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake: new Map([['asset', 0n]]), + }); + + expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(false); + }); + + test('returns true for asset in toStake with non-0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake: new Map([['asset', 1n]]), + }); + + expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(true); + }); + }); + + describe('SpendHelper.shouldConsumeAsset', () => { + test('returns false for asset not in toBurn', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(spendHelper.shouldConsumeAsset('asset')).toBe(false); + }); + + test('returns false for asset in toBurn with 0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['asset', 0n]]), + }); + + expect(spendHelper.shouldConsumeAsset('asset')).toBe(false); + }); + + test('returns true for asset in toBurn with non-0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['asset', 1n]]), + }); + + expect(spendHelper.shouldConsumeAsset('asset')).toBe(true); + }); + + test('returns true for asset in toStake with non-0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake: new Map([['asset', 1n]]), + }); + + expect(spendHelper.shouldConsumeAsset('asset')).toBe(true); + }); + + test('returns false for asset in toStake with 0 value', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake: new Map([['asset', 0n]]), + }); + + expect(spendHelper.shouldConsumeAsset('asset')).toBe(false); + }); + + describe('SpendHelper.consumeLockedAsset', () => { + const testCases = [ + { + description: 'consumes the full amount', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + { + description: 'consumes a partial amount', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 2n, + expected: 1n, + }, + { + description: 'consumes nothing', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 0n, + expected: 0n, + }, + { + description: 'consumes nothing when asset not in toStake', + toStake: new Map(), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toStake with 0 value', + toStake: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + ]; + + test.each(testCases)( + '$description', + ({ toStake, asset, amount, expected }) => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake, + }); + + expect(spendHelper.consumeLockedAsset(asset, amount)).toBe(expected); + }, + ); + + test('throws an error when amount is negative', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(() => { + spendHelper.consumeLockedAsset('asset', -1n); + }).toThrow('Amount to consume must be greater than or equal to 0'); + }); + }); + + describe('SpendHelper.consumeAsset', () => { + const testCases = [ + { + description: 'consumes the full amount', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + { + description: 'consumes a partial amount', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 2n, + expected: 1n, + }, + { + description: 'consumes nothing', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 0n, + expected: 0n, + }, + { + description: 'consumes nothing when asset not in toBurn', + toBurn: new Map(), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toBurn with 0 value', + toBurn: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toStake with 0 value', + toBurn: new Map([['asset', 1n]]), + toStake: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + ]; + + test.each(testCases)( + '$description', + ({ toBurn, asset, amount, expected }) => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn, + }); + + expect(spendHelper.consumeAsset(asset, amount)).toBe(expected); + }, + ); + + test('throws an error when amount is negative', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(() => { + spendHelper.consumeAsset('asset', -1n); + }).toThrow('Amount to consume must be greater than or equal to 0'); + }); + }); + + describe('SpendHelper.verifyAssetsConsumed', () => { + test('returns null when all assets consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['asset', 0n]]), + toStake: new Map([['asset', 0n]]), + }); + + expect(spendHelper.verifyAssetsConsumed()).toBe(null); + }); + + test('returns an error when stake assets not consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['test-asset', 1n]]), + toStake: new Map([['test-asset', 1n]]), + }); + + expect(spendHelper.verifyAssetsConsumed()).toEqual( + new Error( + 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset to stake', + ), + ); + }); + + test('returns an error when burn assets not consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['test-asset', 1n]]), + toStake: new Map([['test-asset', 0n]]), + }); + + expect(spendHelper.verifyAssetsConsumed()).toEqual( + new Error( + 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset', + ), + ); + }); + }); + }); +}); diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 798e14584..e37455884 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -7,7 +7,7 @@ import type { Dimensions } from '../../common/fees/dimensions'; import { addDimensions, dimensionsToGas } from '../../common/fees/dimensions'; import { getInputComplexity, getOutputComplexity } from '../txs/fee'; -interface SpendHelperProps { +export interface SpendHelperProps { changeOutputs: readonly TransferableOutput[]; complexity: Dimensions; gasPrice: bigint; @@ -147,6 +147,10 @@ export class SpendHelper { * @returns {bigint} The remaining amount of the asset after consumption. */ consumeLockedAsset(assetId: string, amount: bigint): bigint { + if (amount < 0n) { + throw new Error('Amount to consume must be greater than or equal to 0'); + } + const assetToStake = this.toStake.get(assetId) ?? 0n; // Stake any value that should be staked @@ -170,6 +174,10 @@ export class SpendHelper { * @returns {bigint} The remaining amount of the asset after consumption. */ consumeAsset(assetId: string, amount: bigint): bigint { + if (amount < 0n) { + throw new Error('Amount to consume must be greater than or equal to 0'); + } + const assetToBurn = this.toBurn.get(assetId) ?? 0n; // Burn any value that should be burned From 2bfa74fb1d53b9eae21c1d099fb1b493f024fce8 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Tue, 10 Sep 2024 10:36:28 -0600 Subject: [PATCH 22/39] refactor: replace unwrapOutput with getUtxoInfo in spend --- src/utils/getUtxoInfo.ts | 42 ++++++++++ src/utils/index.ts | 1 + src/vms/pvm/etna-builder/spend.test.ts | 57 +------------ src/vms/pvm/etna-builder/spend.ts | 107 ++++++++----------------- 4 files changed, 79 insertions(+), 128 deletions(-) create mode 100644 src/utils/getUtxoInfo.ts diff --git a/src/utils/getUtxoInfo.ts b/src/utils/getUtxoInfo.ts new file mode 100644 index 000000000..8a9c45162 --- /dev/null +++ b/src/utils/getUtxoInfo.ts @@ -0,0 +1,42 @@ +import type { Utxo } from '../serializable/avax/utxo'; +import { isStakeableLockOut, isTransferOut } from './typeGuards'; + +export type UtxoInfo = Readonly<{ + /** + * @default 0n + */ + amount: bigint; + assetId: string; + /** + * @default 0n + */ + locktime: bigint; + /** + * @default 0n + */ + stakeableLocktime: bigint; + /** + * @default 1 + */ + threshold: number; + utxoId: string; +}>; + +export const getUtxoInfo = (utxo: Utxo): UtxoInfo => { + const { output } = utxo; + const outputOwners = utxo.getOutputOwners(); + + return { + amount: + isTransferOut(output) || isStakeableLockOut(output) + ? output.amount() + : 0n, + assetId: utxo.getAssetId(), + locktime: outputOwners.locktime.value(), + stakeableLocktime: isStakeableLockOut(output) + ? output.getStakeableLocktime() + : 0n, + threshold: outputOwners.threshold.value(), + utxoId: utxo.ID(), + }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 1013b8a9f..b36f58155 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,6 +11,7 @@ export * from './addChecksum'; export * from './addressMap'; export * from './getTransferableInputsByTx'; export * from './getTransferableOutputsByTx'; +export * from './getUtxoInfo'; export * from './getBurnedAmountByTx'; export * from './validateBurnedAmount'; export { unpackWithManager, getManagerForVM, packTx } from './packTx'; diff --git a/src/vms/pvm/etna-builder/spend.test.ts b/src/vms/pvm/etna-builder/spend.test.ts index 948697d8b..b83fef0fb 100644 --- a/src/vms/pvm/etna-builder/spend.test.ts +++ b/src/vms/pvm/etna-builder/spend.test.ts @@ -1,58 +1,5 @@ -import { - BigIntPr, - Int, - OutputOwners, - TransferOutput, -} from '../../../serializable'; -import { StakeableLockOut } from '../../../serializable/pvm'; -import type { Serializable } from '../../common/types'; -import { unwrapOutput } from './spend'; - describe('./src/vms/pvm/etna-builder/spend.test.ts', () => { - describe('unwrapOutput', () => { - const normalOutput = new TransferOutput( - new BigIntPr(123n), - new OutputOwners(new BigIntPr(456n), new Int(1), []), - ); - - test.each([ - { - name: 'normal output', - testOutput: normalOutput, - expectedOutput: normalOutput, - expectedLocktime: 0n, - expectedError: null, - }, - { - name: 'locked output', - testOutput: new StakeableLockOut(new BigIntPr(789n), normalOutput), - expectedOutput: normalOutput, - expectedLocktime: 789n, - expectedError: null, - }, - { - name: 'locked output with no locktime', - testOutput: new StakeableLockOut(new BigIntPr(0n), normalOutput), - expectedOutput: normalOutput, - expectedLocktime: 0n, - expectedError: null, - }, - { - name: 'invalid output', - testOutput: null as unknown as Serializable, - expectedOutput: null, - expectedLocktime: null, - expectedError: expect.any(Error), - }, - ])( - `$name`, - ({ testOutput, expectedOutput, expectedLocktime, expectedError }) => { - const [error, output, locktime] = unwrapOutput(testOutput); - - expect(error).toEqual(expectedError); - expect(output).toEqual(expectedOutput); - expect(locktime).toEqual(expectedLocktime); - }, - ); + describe('spend', () => { + test.todo('need coverage here'); }); }); diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index baedb220c..5a312e0cc 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -11,11 +11,10 @@ import { } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { isStakeableLockOut, isTransferOut } from '../../../utils'; +import { getUtxoInfo } from '../../../utils'; import { matchOwners } from '../../../utils/matchOwners'; import type { SpendOptions } from '../../common'; import type { Dimensions } from '../../common/fees/dimensions'; -import type { Serializable } from '../../common/types'; import type { Context } from '../../context'; import { SpendHelper } from './spendHelper'; @@ -90,40 +89,6 @@ export const splitByAssetId = ( return { other, requested }; }; -/** - * @internal - * - * Returns the TransferOutput that was, potentially, wrapped by a stakeable lockout. - * - * If the output was stakeable and locked, the locktime is returned. - * Otherwise, the locktime returned will be 0n. - * - * If the output is not an error is returned. - */ -export const unwrapOutput = ( - output: Serializable, -): - | [error: null, transferOutput: TransferOutput, locktime: bigint] - | [error: Error, transferOutput: null, locktime: null] => { - try { - if (isStakeableLockOut(output) && isTransferOut(output.transferOut)) { - return [null, output.transferOut, output.lockTime.value()]; - } else if (isTransferOut(output)) { - return [null, output, 0n]; - } - } catch (error) { - return [ - new Error('An unexpected error occurred while unwrapping output', { - cause: error instanceof Error ? error : undefined, - }), - null, - null, - ]; - } - - return [new Error('Unknown output type'), null, null]; -}; - type SpendResult = Readonly<{ changeOutputs: readonly TransferableOutput[]; inputs: readonly TransferableInput[]; @@ -133,12 +98,11 @@ type SpendResult = Readonly<{ type SpendProps = Readonly<{ /** - * Contains the currently accrued transaction complexity that - * will be used to calculate the required fees to be burned. + * The initial complexity of the transaction. */ complexity: Dimensions; /** - * Contains the amount of extra AVAX that spend can produce in + * The extra AVAX that spend can produce in * the change outputs in addition to the consumed and not burned AVAX. */ excessAVAX?: bigint; @@ -225,15 +189,9 @@ export const spend = ( continue; } - const [unwrapError, out, locktime] = unwrapOutput(utxo.output); - - if (unwrapError) { - return [unwrapError, null]; - } - const { sigIndicies: inputSigIndices } = matchOwners( - out.outputOwners, + utxo.getOutputOwners(), [...fromAddresses], spendOptions.minIssuanceTime, ) || {}; @@ -243,6 +201,8 @@ export const spend = ( continue; } + const utxoInfo = getUtxoInfo(utxo); + spendHelper.addInput( utxo, // TODO: Verify this. @@ -250,15 +210,18 @@ export const spend = ( utxo.utxoId, utxo.assetId, new StakeableLockIn( - new BigIntPr(locktime), - new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + new BigIntPr(utxoInfo.locktime), + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), ), ), ); const excess = spendHelper.consumeLockedAsset( - utxo.assetId.toString(), - out.amount(), + utxoInfo.assetId, + utxoInfo.amount, ); spendHelper.addStakedOutput( @@ -266,10 +229,10 @@ export const spend = ( new TransferableOutput( utxo.assetId, new StakeableLockOut( - new BigIntPr(locktime), + new BigIntPr(utxoInfo.locktime), new TransferOutput( - new BigIntPr(out.amount() - excess), - out.outputOwners, + new BigIntPr(utxoInfo.amount - excess), + utxo.getOutputOwners(), ), ), ), @@ -285,8 +248,8 @@ export const spend = ( new TransferableOutput( utxo.assetId, new StakeableLockOut( - new BigIntPr(locktime), - new TransferOutput(new BigIntPr(excess), out.outputOwners), + new BigIntPr(utxoInfo.locktime), + new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), ), ), ); @@ -320,15 +283,9 @@ export const spend = ( continue; } - const [unwrapError, out] = unwrapOutput(utxo.output); - - if (unwrapError) { - return [unwrapError, null]; - } - const { sigIndicies: inputSigIndices } = matchOwners( - out.outputOwners, + utxo.getOutputOwners(), [...fromAddresses], spendOptions.minIssuanceTime, ) || {}; @@ -338,6 +295,8 @@ export const spend = ( continue; } + const utxoInfo = getUtxoInfo(utxo); + spendHelper.addInput( utxo, // TODO: Verify this. @@ -345,11 +304,14 @@ export const spend = ( new TransferableInput( utxo.utxoId, utxo.assetId, - new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), ), ); - const excess = spendHelper.consumeAsset(assetId, out.amount()); + const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); if (excess === 0n) { continue; @@ -381,15 +343,9 @@ export const spend = ( break; } - const [error, out] = unwrapOutput(utxo.output); - - if (error) { - return [error, null]; - } - const { sigIndicies: inputSigIndices } = matchOwners( - out.outputOwners, + utxo.getOutputOwners(), [...fromAddresses], spendOptions.minIssuanceTime, ) || {}; @@ -399,19 +355,24 @@ export const spend = ( continue; } + const utxoInfo = getUtxoInfo(utxo); + spendHelper.addInput( utxo, // TODO: Verify this. new TransferableInput( utxo.utxoId, utxo.assetId, - new TransferInput(out.amt, Input.fromNative(inputSigIndices)), + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), ), ); const excess = spendHelper.consumeAsset( context.avaxAssetID, - out.amount(), + utxoInfo.amount, ); excessAVAX += excess; From b53906108695da0dca69b142102e8bbfe67c9635 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 11 Sep 2024 09:06:49 -0600 Subject: [PATCH 23/39] refactor: cleanup --- src/vms/pvm/etna-builder/builder.test.ts | 54 ++++++++---------------- src/vms/pvm/etna-builder/spend.ts | 14 +++--- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index 9f052d4c2..14d9e2b49 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -67,51 +67,25 @@ import { proofOfPossession } from '../../../fixtures/pvm'; const testContext: Context = { ..._testContext, - // These are 0 in this context (dynamic fees). - // TODO: Should we assume the context of these will just be setting the values - // to 0n, or should we remove logic in the builder that bundles these static fees - // into the initialization of toBurn? - addPrimaryNetworkValidatorFee: 0n, - addPrimaryNetworkDelegatorFee: 0n, - addSubnetValidatorFee: 0n, - addSubnetDelegatorFee: 0n, - baseTxFee: 0n, - createAssetTxFee: 0n, - createSubnetTxFee: 0n, - createBlockchainTxFee: 0n, - transformSubnetTxFee: 0n, - // Required context for post-Etna gasPrice: 1n, complexityWeights: createDimensions(1, 10, 100, 1000), }; -const addInputAmounts = ( - inputs: readonly TransferableInput[], -): Map => { - const consumed = new Map(); - - for (const input of inputs) { - const assetId = input.getAssetId(); - - consumed.set(assetId, (consumed.get(assetId) ?? 0n) + input.amount()); - } - - return consumed; -}; - -const addOutputAmounts = ( - outputs: readonly TransferableOutput[], +const addTransferableAmounts = ( + transferableItems: + | readonly TransferableOutput[] + | readonly TransferableInput[], ): Map => { - const produced = new Map(); + const amounts = new Map(); - for (const output of outputs) { - const assetId = output.getAssetId(); + for (const transferable of transferableItems) { + const assetId = transferable.getAssetId(); - produced.set(assetId, (produced.get(assetId) ?? 0n) + output.amount()); + amounts.set(assetId, (amounts.get(assetId) ?? 0n) + transferable.amount()); } - return produced; + return amounts; }; const addAmounts = (...amounts: Map[]): Map => { @@ -162,8 +136,14 @@ const checkFeeIsCorrect = ({ expectedAmountConsumed: Record, expectedFee: bigint, ] => { - const amountConsumed = addInputAmounts([...inputs, ...additionalInputs]); - const amountProduced = addOutputAmounts([...outputs, ...additionalOutputs]); + const amountConsumed = addTransferableAmounts([ + ...inputs, + ...additionalInputs, + ]); + const amountProduced = addTransferableAmounts([ + ...outputs, + ...additionalOutputs, + ]); const expectedFee = calculateFee( unsignedTx.getTx(), diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 5a312e0cc..43806d83b 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -96,7 +96,7 @@ type SpendResult = Readonly<{ stakeOutputs: readonly TransferableOutput[]; }>; -type SpendProps = Readonly<{ +export type SpendProps = Readonly<{ /** * The initial complexity of the transaction. */ @@ -153,7 +153,7 @@ export const spend = ( complexity, excessAVAX: _excessAVAX = 0n, fromAddresses, - ownerOverride: _ownerOverride, + ownerOverride, spendOptions, toBurn = new Map(), toStake = new Map(), @@ -164,8 +164,8 @@ export const spend = ( | [error: null, inputsAndOutputs: SpendResult] | [error: Error, inputsAndOutputs: null] => { try { - let ownerOverride = - _ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + let changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); let excessAVAX: bigint = _excessAVAX; const spendHelper = new SpendHelper({ @@ -379,7 +379,7 @@ export const spend = ( // If we need to consume additional AVAX, we should be returning the // change to the change address. - ownerOverride = OutputOwners.fromNative(spendOptions.changeAddresses); + changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); } // Verify @@ -402,7 +402,7 @@ export const spend = ( spendHelper.addOutputComplexity( new TransferableOutput( Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), ownerOverride), + new TransferOutput(new BigIntPr(0n), changeOwners), ), ); @@ -415,7 +415,7 @@ export const spend = ( Id.fromString(context.avaxAssetID), new TransferOutput( new BigIntPr(excessAVAX - requiredFeeWithChange), - ownerOverride, + changeOwners, ), ), ); From afa2f2d75ee02840e7e3bcfc4d496e0b82a75dc8 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Wed, 11 Sep 2024 13:28:50 -0600 Subject: [PATCH 24/39] refactor: use stackableLocktime --- src/vms/pvm/etna-builder/spend.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 43806d83b..ae45032ad 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -40,8 +40,10 @@ export const splitByLocktime = ( for (const utxo of utxos) { let utxoOwnersLocktime: bigint; + // TODO: Remove this try catch in the future in favor of + // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs try { - utxoOwnersLocktime = utxo.getOutputOwners().locktime.value(); + utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; } catch (error) { // If we can't get the locktime, we can't spend the UTXO. // TODO: Is this the right thing to do? @@ -210,7 +212,7 @@ export const spend = ( utxo.utxoId, utxo.assetId, new StakeableLockIn( - new BigIntPr(utxoInfo.locktime), + new BigIntPr(utxoInfo.stakeableLocktime), new TransferInput( new BigIntPr(utxoInfo.amount), Input.fromNative(inputSigIndices), @@ -229,7 +231,7 @@ export const spend = ( new TransferableOutput( utxo.assetId, new StakeableLockOut( - new BigIntPr(utxoInfo.locktime), + new BigIntPr(utxoInfo.stakeableLocktime), new TransferOutput( new BigIntPr(utxoInfo.amount - excess), utxo.getOutputOwners(), @@ -248,7 +250,7 @@ export const spend = ( new TransferableOutput( utxo.assetId, new StakeableLockOut( - new BigIntPr(utxoInfo.locktime), + new BigIntPr(utxoInfo.stakeableLocktime), new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), ), ), From 36dd950f18c30054f621c1b9d3d7a5de661d4f1d Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 13 Sep 2024 14:02:42 -0600 Subject: [PATCH 25/39] feat: add p-chain etna examples --- examples/p-chain/etna/base.ts | 42 ++++++++++++++ examples/p-chain/etna/delegate.ts | 49 +++++++++++++++++ examples/p-chain/etna/export.ts | 43 +++++++++++++++ examples/p-chain/etna/import.ts | 36 ++++++++++++ examples/p-chain/etna/utils/etna-context.ts | 16 ++++++ examples/p-chain/etna/validate.ts | 61 +++++++++++++++++++++ examples/utils/getEnvVars.ts | 17 ++++++ src/vms/context/context.ts | 4 +- 8 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 examples/p-chain/etna/base.ts create mode 100644 examples/p-chain/etna/delegate.ts create mode 100644 examples/p-chain/etna/export.ts create mode 100644 examples/p-chain/etna/import.ts create mode 100644 examples/p-chain/etna/utils/etna-context.ts create mode 100644 examples/p-chain/etna/validate.ts create mode 100644 examples/utils/getEnvVars.ts diff --git a/examples/p-chain/etna/base.ts b/examples/p-chain/etna/base.ts new file mode 100644 index 000000000..78d40c0c5 --- /dev/null +++ b/examples/p-chain/etna/base.ts @@ -0,0 +1,42 @@ +import { TransferableOutput, addTxSignatures, pvm, utils } from '../../../src'; +import { getEnvVars } from '../../utils/getEnvVars'; +import { getEtnaContextFromURI } from './utils/etna-context'; + +/** + * The amount of AVAX to send to self. + */ +const SEND_AVAX_AMOUNT: number = 0.001; + +const main = async () => { + const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY } = getEnvVars(); + + const pvmApi = new pvm.PVMApi(AVAX_PUBLIC_URL); + + const context = await getEtnaContextFromURI(AVAX_PUBLIC_URL); + + const { utxos } = await pvmApi.getUTXOs({ addresses: [P_CHAIN_ADDRESS] }); + + const tx = pvm.e.newBaseTx( + { + fromAddressesBytes: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + outputs: [ + TransferableOutput.fromNative( + context.avaxAssetID, + BigInt(SEND_AVAX_AMOUNT * 1e9), + [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + ), + ], + utxos, + }, + context, + ); + + await addTxSignatures({ + unsignedTx: tx, + privateKeys: [utils.hexToBuffer(PRIVATE_KEY)], + }); + + return pvmApi.issueSignedTx(tx.getSignedTx()); +}; + +main().then(console.log); diff --git a/examples/p-chain/etna/delegate.ts b/examples/p-chain/etna/delegate.ts new file mode 100644 index 000000000..dc1ea92be --- /dev/null +++ b/examples/p-chain/etna/delegate.ts @@ -0,0 +1,49 @@ +import { addTxSignatures, networkIDs, pvm, utils } from '../../../src'; +import { getEnvVars } from '../../utils/getEnvVars'; +import { getEtnaContextFromURI } from './utils/etna-context'; + +const AMOUNT_TO_DELEGATE_AVAX: number = 1; +const DAYS_TO_DELEGATE: number = 21; + +const main = async () => { + const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY } = getEnvVars(); + + const pvmApi = new pvm.PVMApi(AVAX_PUBLIC_URL); + + const context = await getEtnaContextFromURI(AVAX_PUBLIC_URL); + + const { utxos } = await pvmApi.getUTXOs({ addresses: [P_CHAIN_ADDRESS] }); + + const startTime = await pvmApi.getTimestamp(); + const startDate = new Date(startTime.timestamp); + const start: bigint = BigInt(startDate.getTime() / 1_000); + + const endTime = new Date(startTime.timestamp); + endTime.setDate(endTime.getDate() + DAYS_TO_DELEGATE); + const end: bigint = BigInt(endTime.getTime() / 1_000); + + const nodeId = 'NodeID-HKLp5269LH8DcrLvHPc2PHjGczBQD3td4'; + + const tx = pvm.e.newAddPermissionlessDelegatorTx( + { + end, + fromAddressesBytes: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + nodeId, + rewardAddresses: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + start, + subnetId: networkIDs.PrimaryNetworkID.toString(), + utxos, + weight: BigInt(AMOUNT_TO_DELEGATE_AVAX * 1e9), + }, + context, + ); + + await addTxSignatures({ + unsignedTx: tx, + privateKeys: [utils.hexToBuffer(PRIVATE_KEY)], + }); + + return pvmApi.issueSignedTx(tx.getSignedTx()); +}; + +main().then(console.log); diff --git a/examples/p-chain/etna/export.ts b/examples/p-chain/etna/export.ts new file mode 100644 index 000000000..233560361 --- /dev/null +++ b/examples/p-chain/etna/export.ts @@ -0,0 +1,43 @@ +import { TransferableOutput, addTxSignatures, pvm, utils } from '../../../src'; +import { getEnvVars } from '../../utils/getEnvVars'; +import { getEtnaContextFromURI } from './utils/etna-context'; + +const AMOUNT_TO_EXPORT_AVAX: number = 0.001; + +const main = async () => { + const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY, X_CHAIN_ADDRESS } = + getEnvVars(); + + const context = await getEtnaContextFromURI(AVAX_PUBLIC_URL); + + const pvmApi = new pvm.PVMApi(AVAX_PUBLIC_URL); + + const { utxos } = await pvmApi.getUTXOs({ + addresses: [P_CHAIN_ADDRESS], + }); + + const exportTx = pvm.e.newExportTx( + { + destinationChainId: context.xBlockchainID, + fromAddressesBytes: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + outputs: [ + TransferableOutput.fromNative( + context.avaxAssetID, + BigInt(AMOUNT_TO_EXPORT_AVAX * 1e9), + [utils.bech32ToBytes(X_CHAIN_ADDRESS)], + ), + ], + utxos, + }, + context, + ); + + await addTxSignatures({ + unsignedTx: exportTx, + privateKeys: [utils.hexToBuffer(PRIVATE_KEY)], + }); + + return pvmApi.issueSignedTx(exportTx.getSignedTx()); +}; + +main().then(console.log); diff --git a/examples/p-chain/etna/import.ts b/examples/p-chain/etna/import.ts new file mode 100644 index 000000000..82bb6299c --- /dev/null +++ b/examples/p-chain/etna/import.ts @@ -0,0 +1,36 @@ +import { addTxSignatures, pvm, utils } from '../../../src'; +import { getEnvVars } from '../../utils/getEnvVars'; +import { getEtnaContextFromURI } from './utils/etna-context'; + +const main = async () => { + const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY, X_CHAIN_ADDRESS } = + getEnvVars(); + + const context = await getEtnaContextFromURI(AVAX_PUBLIC_URL); + + const pvmApi = new pvm.PVMApi(AVAX_PUBLIC_URL); + + const { utxos } = await pvmApi.getUTXOs({ + sourceChain: 'X', + addresses: [P_CHAIN_ADDRESS], + }); + + const importTx = pvm.e.newImportTx( + { + fromAddressesBytes: [utils.bech32ToBytes(X_CHAIN_ADDRESS)], + sourceChainId: context.xBlockchainID, + toAddresses: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + utxos, + }, + context, + ); + + await addTxSignatures({ + unsignedTx: importTx, + privateKeys: [utils.hexToBuffer(PRIVATE_KEY)], + }); + + return pvmApi.issueSignedTx(importTx.getSignedTx()); +}; + +main().then(console.log); diff --git a/examples/p-chain/etna/utils/etna-context.ts b/examples/p-chain/etna/utils/etna-context.ts new file mode 100644 index 000000000..230a421f9 --- /dev/null +++ b/examples/p-chain/etna/utils/etna-context.ts @@ -0,0 +1,16 @@ +import { Context } from '../../../../src'; + +/** + * Gets the context from URI and then modifies the context + * to be used for testing example Etna transactions until Etna is enabled. + */ +export const getEtnaContextFromURI = async ( + uri: string, +): Promise => { + const context = await Context.getContextFromURI(uri); + + return { + ...context, + gasPrice: 10_000n, + }; +}; diff --git a/examples/p-chain/etna/validate.ts b/examples/p-chain/etna/validate.ts new file mode 100644 index 000000000..a8e24f5c5 --- /dev/null +++ b/examples/p-chain/etna/validate.ts @@ -0,0 +1,61 @@ +import { addTxSignatures, networkIDs, pvm, utils } from '../../../src'; +import { getEnvVars } from '../../utils/getEnvVars'; +import { getEtnaContextFromURI } from './utils/etna-context'; + +const AMOUNT_TO_VALIDATE_AVAX: number = 1; +const DAYS_TO_VALIDATE: number = 21; + +const main = async () => { + const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY } = getEnvVars(); + + const pvmApi = new pvm.PVMApi(AVAX_PUBLIC_URL); + + const context = await getEtnaContextFromURI(AVAX_PUBLIC_URL); + + const { utxos } = await pvmApi.getUTXOs({ addresses: [P_CHAIN_ADDRESS] }); + + const startTime = await pvmApi.getTimestamp(); + const startDate = new Date(startTime.timestamp); + const start: bigint = BigInt(startDate.getTime() / 1_000); + + const endTime = new Date(startTime.timestamp); + endTime.setDate(endTime.getDate() + DAYS_TO_VALIDATE); + const end: bigint = BigInt(endTime.getTime() / 1_000); + + const nodeId = 'NodeID-HKLp5269LH8DcrLvNDoJquQs2w1LwLCga'; + + const publicKey = utils.hexToBuffer( + '0x8f95423f7142d00a48e1014a3de8d28907d420dc33b3052a6dee03a3f2941a393c2351e354704ca66a3fc29870282e15', + ); + + const signature = utils.hexToBuffer( + '0x86a3ab4c45cfe31cae34c1d06f212434ac71b1be6cfe046c80c162e057614a94a5bc9f1ded1a7029deb0ba4ca7c9b71411e293438691be79c2dbf19d1ca7c3eadb9c756246fc5de5b7b89511c7d7302ae051d9e03d7991138299b5ed6a570a98', + ); + + const tx = pvm.e.newAddPermissionlessValidatorTx( + { + end, + delegatorRewardsOwner: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + fromAddressesBytes: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + nodeId, + publicKey, + rewardAddresses: [utils.bech32ToBytes(P_CHAIN_ADDRESS)], + shares: 20 * 1e4, + signature, + start, + subnetId: networkIDs.PrimaryNetworkID.toString(), + utxos, + weight: BigInt(AMOUNT_TO_VALIDATE_AVAX * 1e9), + }, + context, + ); + + await addTxSignatures({ + unsignedTx: tx, + privateKeys: [utils.hexToBuffer(PRIVATE_KEY)], + }); + + return pvmApi.issueSignedTx(tx.getSignedTx()); +}; + +main().then(console.log); diff --git a/examples/utils/getEnvVars.ts b/examples/utils/getEnvVars.ts new file mode 100644 index 000000000..a92f92930 --- /dev/null +++ b/examples/utils/getEnvVars.ts @@ -0,0 +1,17 @@ +const AVAX_PUBLIC_URL = process.env['AVAX_PUBLIC_URL']; +const P_CHAIN_ADDRESS = process.env['P_CHAIN_ADDRESS']; +const PRIVATE_KEY = process.env['PRIVATE_KEY']; +const X_CHAIN_ADDRESS = process.env['X_CHAIN_ADDRESS']; + +export const getEnvVars = () => { + if (!(AVAX_PUBLIC_URL && P_CHAIN_ADDRESS && PRIVATE_KEY && X_CHAIN_ADDRESS)) { + throw new Error('Missing environment variable(s).'); + } + + return { + AVAX_PUBLIC_URL, + P_CHAIN_ADDRESS, + PRIVATE_KEY, + X_CHAIN_ADDRESS, + }; +}; diff --git a/src/vms/context/context.ts b/src/vms/context/context.ts index a967b1c6b..16f55ab71 100644 --- a/src/vms/context/context.ts +++ b/src/vms/context/context.ts @@ -1,7 +1,7 @@ import { getHRP } from '../../constants/networkIDs'; import { Info } from '../../info/info'; import { AVMApi } from '../avm/api'; -import { createEmptyDimensions } from '../common/fees/dimensions'; +import { createDimensions } from '../common/fees/dimensions'; import type { Context } from './model'; /* @@ -52,6 +52,6 @@ export const getContextFromURI = async ( // TODO: Populate these values once they are exposed by the API gasPrice: 0n, - complexityWeights: createEmptyDimensions(), + complexityWeights: createDimensions(1, 1, 1, 1), }); }; From 8007fa15e6113d5355b69033d02cc7e8b852f02c Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 19 Sep 2024 10:03:15 -0600 Subject: [PATCH 26/39] feat: add new etna experimental spend --- src/serializable/avax/utxo.ts | 4 +- src/serializable/pvm/stakeableLockOut.ts | 6 +- src/utils/consolidate.ts | 2 +- src/vms/pvm/builder.spec.ts | 4 +- src/vms/pvm/etna-builder/builder.ts | 9 +- .../pvm/etna-builder/experimental-spend.ts | 534 ++++++++++++++++++ src/vms/pvm/etna-builder/spend.ts | 4 +- src/vms/pvm/etna-builder/spendHelper.test.ts | 37 +- src/vms/pvm/etna-builder/spendHelper.ts | 95 +++- .../utils/verifySignaturesMatch.ts | 12 +- src/vms/utils/consolidateOutputs.ts | 2 +- 11 files changed, 652 insertions(+), 57 deletions(-) create mode 100644 src/vms/pvm/etna-builder/experimental-spend.ts diff --git a/src/serializable/avax/utxo.ts b/src/serializable/avax/utxo.ts index ec71be13e..7e20af3c3 100644 --- a/src/serializable/avax/utxo.ts +++ b/src/serializable/avax/utxo.ts @@ -12,13 +12,13 @@ import { TypeSymbols } from '../constants'; * @see https://docs.avax.network/specs/avm-transaction-serialization#unsigned-Exporttx */ @serializable() -export class Utxo { +export class Utxo { _type = TypeSymbols.UTXO; constructor( public readonly utxoId: UTXOID, public readonly assetId: Id, - public readonly output: Serializable, + public readonly output: Output, ) {} static fromBytes(bytes: Uint8Array, codec: Codec): [Utxo, Uint8Array] { diff --git a/src/serializable/pvm/stakeableLockOut.ts b/src/serializable/pvm/stakeableLockOut.ts index 9c9a9b486..9c3d3a58a 100644 --- a/src/serializable/pvm/stakeableLockOut.ts +++ b/src/serializable/pvm/stakeableLockOut.ts @@ -11,12 +11,14 @@ import { TypeSymbols } from '../constants'; * @see https://docs.avax.network/specs/platform-transaction-serialization#stakeablelockin */ @serializable() -export class StakeableLockOut implements Amounter { +export class StakeableLockOut + implements Amounter +{ _type = TypeSymbols.StakeableLockOut; constructor( public readonly lockTime: BigIntPr, - public readonly transferOut: Amounter, + public readonly transferOut: TransferOut, ) {} amount() { diff --git a/src/utils/consolidate.ts b/src/utils/consolidate.ts index 23f732843..86621e66a 100644 --- a/src/utils/consolidate.ts +++ b/src/utils/consolidate.ts @@ -9,7 +9,7 @@ * @returns an array combined elements */ export const consolidate = ( - arr: T[], + arr: readonly T[], canCombine: (a: T, b: T) => boolean, combine: (a: T, b: T) => T, ): T[] => { diff --git a/src/vms/pvm/builder.spec.ts b/src/vms/pvm/builder.spec.ts index 1700b9b77..1b34e30db 100644 --- a/src/vms/pvm/builder.spec.ts +++ b/src/vms/pvm/builder.spec.ts @@ -220,7 +220,7 @@ describe('pvmBuilder', () => { }); it('AddValidatorTx - stakeable locked', () => { - const utxos = testUtxos(); + const utxos: Utxo[] = testUtxos(); const lockTime = BigInt(Math.floor(new Date().getTime() / 1000)) + 10000n; const lockedUtxo = new Utxo( new UTXOID(testUTXOID1, new Int(0)), @@ -265,7 +265,7 @@ describe('pvmBuilder', () => { }); it('AddDelegatorTx', () => { - const utxos = testUtxos(); + const utxos: Utxo[] = testUtxos(); const lockTime = BigInt(Math.floor(new Date().getTime() / 1000)) + 10000n; const lockedUtxo = new Utxo( new UTXOID(testUTXOID1, new Int(0)), diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index fff2d1157..358a81b3b 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -65,7 +65,7 @@ import { getOwnerComplexity, getSignerComplexity, } from '../txs/fee'; -import { spend } from './spend'; +import { spend } from './experimental-spend'; const getAddressMaps = ({ inputs, @@ -317,8 +317,6 @@ export const newImportTx: TxBuilderFn = ( outputComplexity, ); - const toBurn = new Map(); - const [error, spendResults] = spend( { complexity, @@ -326,7 +324,6 @@ export const newImportTx: TxBuilderFn = ( fromAddresses, ownerOverride: OutputOwners.fromNative(toAddresses, locktime, threshold), spendOptions: defaultedOptions, - toBurn, utxos, }, context, @@ -892,7 +889,6 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< context, ) => { const isPrimaryNetwork = subnetId === PrimaryNetworkID.toString(); - const toBurn = new Map(); const assetId = stakingAssetId ?? context.avaxAssetID; @@ -935,7 +931,6 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn, toStake, utxos, }, @@ -1058,7 +1053,6 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< if (isPrimaryNetwork && assetId !== context.avaxAssetID) throw new Error('Staking asset ID must be AVAX for the primary network.'); - const toBurn = new Map(); const toStake = new Map([[assetId, weight]]); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); @@ -1085,7 +1079,6 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), spendOptions: defaultedOptions, - toBurn, toStake, utxos, }, diff --git a/src/vms/pvm/etna-builder/experimental-spend.ts b/src/vms/pvm/etna-builder/experimental-spend.ts new file mode 100644 index 000000000..a17fb900c --- /dev/null +++ b/src/vms/pvm/etna-builder/experimental-spend.ts @@ -0,0 +1,534 @@ +import type { Address } from '../../../serializable'; +import { + BigIntPr, + OutputOwners, + TransferInput, + TransferableInput, + TransferableOutput, + TransferOutput, + Id, +} from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; +import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; +import type { SpendOptions } from '../../common'; +import type { Dimensions } from '../../common/fees/dimensions'; +import type { Context } from '../../context'; +import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; +import { SpendHelper } from './spendHelper'; + +type SpendResult = Readonly<{ + /** + * The consolidated and sorted change outputs. + */ + changeOutputs: readonly TransferableOutput[]; + /** + * The total calculated fee for the transaction. + */ + fee: bigint; + /** + * The sorted inputs. + */ + inputs: readonly TransferableInput[]; + /** + * The UTXOs that were used as inputs. + */ + inputUTXOs: readonly Utxo[]; + /** + * The consolidated and sorted staked outputs. + */ + stakeOutputs: readonly TransferableOutput[]; +}>; + +export type SpendProps = Readonly<{ + /** + * The initial complexity of the transaction. + */ + complexity: Dimensions; + /** + * The extra AVAX that spend can produce in + * the change outputs in addition to the consumed and not burned AVAX. + */ + excessAVAX?: bigint; + /** + * List of Addresses that are used for selecting which UTXOs are signable. + */ + fromAddresses: readonly Address[]; + /** + * Optionally specifies the output owners to use for the unlocked + * AVAX change output if no additional AVAX was needed to be burned. + * If this value is `undefined` or `null`, the default change owner is used. + * + * Used in ImportTx. + */ + ownerOverride?: OutputOwners | null; + spendOptions: Required; + /** + * Maps `assetID` to the amount of the asset to spend without + * producing an output. This is typically used for fees. + * However, it can also be used to consume some of an asset that + * will be produced in separate outputs, such as ExportedOutputs. + * + * Only unlocked UTXOs are able to be burned here. + */ + toBurn?: Map; + /** + * Maps `assetID` to the amount of the asset to spend and place info + * the staked outputs. First locked UTXOs are attempted to be used for + * these funds, and then unlocked UTXOs will be attempted to be used. + * There is no preferential ordering on the unlock times. + */ + toStake?: Map; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +type SpendReducerState = Readonly>; + +type SpendReducerFunction = ( + state: SpendReducerState, + spendHelper: SpendHelper, + context: Context, +) => SpendReducerState; + +const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { + const verifyError = spendHelper.verifyAssetsConsumed(); + + if (verifyError) { + throw verifyError; + } + + return state; +}; + +export const IncorrectStakeableLockOutError = new Error( + 'StakeableLockOut transferOut must be a TransferOutput.', +); + +export const useSpendableLockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter((utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; + } + + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } + + // 1c. Ensure there are funds to stake. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; + } + + // 1d. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return true; + }); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Do all the logic for spending based on the UTXOs. + for (const { sigData, data: utxo } of verifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. + if (remainingAmountToStake === 0n) { + continue; + } + + // 3b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + // StakeableLockOut + new BigIntPr(utxoInfo.stakeableLocktime), + TransferInput.fromNative( + // TransferOutput + utxoInfo.amount, + sigData.sigIndicies, + ), + ), + ), + ); + + // 3c. Consume the locked asset and get the remaining amount. + const remainingAmount = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 3d. Add the stake output. + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + + // 3e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + } + } + + // 4. Add all remaining stake amounts assuming they are unlocked. + for (const [assetId, amount] of state.toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + state.spendOptions.changeAddresses, + ), + ); + } + + return state; +}; + +export const useUnlockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = + state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter( + ( + utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return ( + utxo.output.getLocktime() < state.spendOptions.minIssuanceTime + ); + } + + return false; + }, + ); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => + isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. + const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = + verifiedUsableUTXOs.reduce( + (result, { sigData, data: utxo }) => { + if (utxo.assetId.value() === context.avaxAssetID) { + return [result[0], [...result[1], { sigData, data: utxo }]]; + } + + return [[...result[0], { sigData, data: utxo }], result[1]]; + }, + [[], []] as [ + other: typeof verifiedUsableUTXOs, + avax: typeof verifiedUsableUTXOs, + ], + ); + + // 4. Handle all the non-AVAX asset UTXOs first. + for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToBurn: bigint = + state.toBurn.get(utxoInfo.assetId) ?? 0n; + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. + if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { + continue; + } + + // 4b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + // 4c. Consume the asset and get the remaining amount. + const remainingAmount = spendHelper.consumeAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 4d. If "amountToStake" is greater than 0, add the stake output. + // TODO: Implement or determine if needed. + + // 4e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(remainingAmount), + OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ), + ), + ), + ), + ); + } + } + + // 5. Handle AVAX asset UTXOs last to account for fees. + let excessAVAX = state.excessAVAX; + let clearOwnerOverride = false; + for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + const remainingAmount = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); + + excessAVAX += remainingAmount; + + // The ownerOverride is no longer needed. Clear it. + clearOwnerOverride = true; + } + + return { + ...state, + excessAVAX, + ownerOverride: clearOwnerOverride ? null : state.ownerOverride, + }; +}; + +export const handleFee: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + const requiredFee = spendHelper.calculateFee(); + + if (state.excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - state.excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } + + // No need to add a change output. + if (state.excessAVAX === requiredFee) { + return state; + } + + // Use the change owner override if it exists, otherwise use the default change owner. + // This is used for import transactions. + const changeOwners = + state.ownerOverride || + OutputOwners.fromNative(state.spendOptions.changeAddresses); + + // TODO: Clean-up if this is no longer needed. + // Additionally, no need for public .addOutputComplexity(). + // + // Pre-consolidation code. + // + // spendHelper.addOutputComplexity( + // new TransferableOutput( + // Id.fromString(context.avaxAssetID), + // new TransferOutput(new BigIntPr(0n), changeOwners), + // ), + // ); + // + // Recalculate the fee with the change output. + // const requiredFeeWithChange = spendHelper.calculateFee(); + + // Calculate the fee with a temporary output complexity if a change output is needed. + const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( + context.avaxAssetID, + changeOwners, + ) + ? requiredFee + : spendHelper.calculateFeeWithTemporaryOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); + + // Add a change output if needed. + if (state.excessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFeeWithChange), + changeOwners, + ), + ), + ); + } + + return state; +}; + +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + complexity, + excessAVAX: _excessAVAX = 0n, + fromAddresses, + ownerOverride, + spendOptions, + toBurn = new Map(), + toStake = new Map(), + utxos, + }: SpendProps, + // spendReducers: readonly SpendReducerFunction[], + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + const changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + const excessAVAX: bigint = _excessAVAX; + + const spendHelper = new SpendHelper({ + changeOutputs: [], + complexity, + gasPrice: context.gasPrice, + inputs: [], + stakeOutputs: [], + toBurn, + toStake, + weights: context.complexityWeights, + }); + + const initialState: SpendReducerState = { + complexity, + excessAVAX, + fromAddresses, + ownerOverride: changeOwners, + spendOptions, + toBurn, + toStake, + utxos, + }; + + const spendReducerFunctions: readonly SpendReducerFunction[] = [ + // ...spendReducers, + useSpendableLockedUTXOs, + useUnlockedUTXOs, + verifyAssetsConsumed, + handleFee, + // Consolidation and sorting happens in the SpendHelper. + ]; + + // Run all the spend calculation reducer logic. + spendReducerFunctions.reduce((state, next) => { + return next(state, spendHelper, context); + }, initialState); + + return [null, spendHelper.getInputsOutputs()]; + } catch (error) { + return [ + error instanceof Error + ? error + : new Error('An unexpected error occurred during spend calculation'), + null, + ]; + } +}; diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index ae45032ad..c7fa534dc 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -177,7 +177,7 @@ export const spend = ( inputs: [], stakeOutputs: [], toBurn, - toStake: toStake ?? new Map(), + toStake, weights: context.complexityWeights, }); @@ -396,7 +396,7 @@ export const spend = ( throw new Error( `Insufficient funds: provided UTXOs need ${ requiredFee - excessAVAX - } more nAVAX (${context.avaxAssetID})`, + } more nAVAX (asset id: ${context.avaxAssetID})`, ); } diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 7d9d3df0d..820cb001c 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -3,7 +3,10 @@ import { transferableOutput, utxo, } from '../../../fixtures/avax'; -import { createDimensions } from '../../common/fees/dimensions'; +import { + createDimensions, + dimensionsToGas, +} from '../../common/fees/dimensions'; import type { SpendHelperProps } from './spendHelper'; import { SpendHelper } from './spendHelper'; @@ -23,27 +26,31 @@ const DEFAULT_PROPS: SpendHelperProps = { }; describe('src/vms/pvm/etna-builder/spendHelper', () => { - describe('SpendHelper', () => { - test('initialized with correct values', () => { - const spendHelper = new SpendHelper(DEFAULT_PROPS); + test('initialized with correct values', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); - expect(spendHelper).toBeInstanceOf(SpendHelper); + expect(spendHelper).toBeInstanceOf(SpendHelper); - const results = spendHelper.getInputsOutputs(); + const results = spendHelper.getInputsOutputs(); - expect(results.changeOutputs).toEqual([]); - expect(results.inputs).toEqual([]); - expect(results.inputUTXOs).toEqual([]); - expect(results.stakeOutputs).toEqual([]); - }); + expect(results.changeOutputs).toEqual([]); + expect(results.fee).toBe( + dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + DEFAULT_GAS_PRICE, + ); + expect(results.inputs).toEqual([]); + expect(results.inputUTXOs).toEqual([]); + expect(results.stakeOutputs).toEqual([]); }); test('adding inputs and outputs', () => { const spendHelper = new SpendHelper(DEFAULT_PROPS); - expect(spendHelper.calculateFee()).toBe(30n); expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [], + fee: + dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + DEFAULT_GAS_PRICE, inputs: [], inputUTXOs: [], stakeOutputs: [], @@ -58,9 +65,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { spendHelper.addInput(inputUtxo, inputTransferableInput); - expect(spendHelper.calculateFee()).toBe(1251n); expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [], + fee: 1251n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [], @@ -70,9 +77,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { spendHelper.addChangeOutput(changeOutput); - expect(spendHelper.calculateFee()).toBe(1560n); expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [changeOutput], + fee: 1560n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [], @@ -82,9 +89,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { spendHelper.addStakedOutput(stakeOutput); - expect(spendHelper.calculateFee()).toBe(1869n); expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [changeOutput], + fee: 1869n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [stakeOutput], diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index e37455884..6a844f323 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -1,10 +1,16 @@ -import type { TransferableOutput } from '../../../serializable'; +import type { OutputOwners, TransferableOutput } from '../../../serializable'; import { TransferableInput } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; +import { isTransferOut } from '../../../utils'; import { bigIntMin } from '../../../utils/bigintMath'; import { compareTransferableOutputs } from '../../../utils/sort'; import type { Dimensions } from '../../common/fees/dimensions'; -import { addDimensions, dimensionsToGas } from '../../common/fees/dimensions'; +import { + addDimensions, + createEmptyDimensions, + dimensionsToGas, +} from '../../common/fees/dimensions'; +import { consolidateOutputs } from '../../utils/consolidateOutputs'; import { getInputComplexity, getOutputComplexity } from '../txs/fee'; export interface SpendHelperProps { @@ -26,13 +32,15 @@ export interface SpendHelperProps { */ export class SpendHelper { private readonly gasPrice: bigint; + private readonly initialComplexity: Dimensions; private readonly toBurn: Map; private readonly toStake: Map; private readonly weights: Dimensions; - private complexity: Dimensions; private changeOutputs: readonly TransferableOutput[]; private inputs: readonly TransferableInput[]; + private inputComplexity: Dimensions = createEmptyDimensions(); + private outputComplexity: Dimensions = createEmptyDimensions(); private stakeOutputs: readonly TransferableOutput[]; private inputUTXOs: readonly Utxo[] = []; @@ -48,11 +56,11 @@ export class SpendHelper { weights, }: SpendHelperProps) { this.gasPrice = gasPrice; + this.initialComplexity = complexity; this.toBurn = toBurn; this.toStake = toStake; this.weights = weights; - this.complexity = complexity; this.changeOutputs = changeOutputs; this.inputs = inputs; this.stakeOutputs = stakeOutputs; @@ -68,7 +76,10 @@ export class SpendHelper { addInput(utxo: Utxo, transferableInput: TransferableInput): SpendHelper { const newInputComplexity = getInputComplexity([transferableInput]); - this.complexity = addDimensions(this.complexity, newInputComplexity); + this.inputComplexity = addDimensions( + this.inputComplexity, + newInputComplexity, + ); this.inputs = [...this.inputs, transferableInput]; this.inputUTXOs = [...this.inputUTXOs, utxo]; @@ -86,7 +97,7 @@ export class SpendHelper { addChangeOutput(transferableOutput: TransferableOutput): SpendHelper { this.changeOutputs = [...this.changeOutputs, transferableOutput]; - return this.addOutputComplexity(transferableOutput); + return this; } /** @@ -99,7 +110,7 @@ export class SpendHelper { addStakedOutput(transferableOutput: TransferableOutput): SpendHelper { this.stakeOutputs = [...this.stakeOutputs, transferableOutput]; - return this.addOutputComplexity(transferableOutput); + return this; } /** @@ -111,11 +122,29 @@ export class SpendHelper { addOutputComplexity(transferableOutput: TransferableOutput): SpendHelper { const newOutputComplexity = getOutputComplexity([transferableOutput]); - this.complexity = addDimensions(this.complexity, newOutputComplexity); + this.outputComplexity = addDimensions( + this.outputComplexity, + newOutputComplexity, + ); return this; } + private getComplexity(): Dimensions { + return addDimensions( + this.initialComplexity, + getInputComplexity(this.inputs), + getOutputComplexity(this.changeOutputs), + getOutputComplexity(this.stakeOutputs), + this.outputComplexity, + ); + } + + private consolidateOutputs(): void { + this.changeOutputs = consolidateOutputs(this.changeOutputs); + this.stakeOutputs = consolidateOutputs(this.stakeOutputs); + } + /** * Determines if a locked asset should be consumed based on its asset ID. * @@ -151,19 +180,19 @@ export class SpendHelper { throw new Error('Amount to consume must be greater than or equal to 0'); } - const assetToStake = this.toStake.get(assetId) ?? 0n; + const remainingAmountToStake = this.toStake.get(assetId) ?? 0n; // Stake any value that should be staked - const toStake = bigIntMin( + const amountToStake = bigIntMin( // Amount we still need to stake - assetToStake, + remainingAmountToStake, // Amount available to stake amount, ); - this.toStake.set(assetId, assetToStake - toStake); + this.toStake.set(assetId, remainingAmountToStake - amountToStake); - return amount - toStake; + return amount - amountToStake; } /** @@ -178,20 +207,20 @@ export class SpendHelper { throw new Error('Amount to consume must be greater than or equal to 0'); } - const assetToBurn = this.toBurn.get(assetId) ?? 0n; + const remainingAmountToBurn = this.toBurn.get(assetId) ?? 0n; // Burn any value that should be burned - const toBurn = bigIntMin( + const amountToBurn = bigIntMin( // Amount we still need to burn - assetToBurn, + remainingAmountToBurn, // Amount available to burn amount, ); - this.toBurn.set(assetId, assetToBurn - toBurn); + this.toBurn.set(assetId, remainingAmountToBurn - amountToBurn); // Stake any remaining value that should be staked - return this.consumeLockedAsset(assetId, amount - toBurn); + return this.consumeLockedAsset(assetId, amount - amountToBurn); } /** @@ -200,11 +229,35 @@ export class SpendHelper { * @returns {bigint} The fee for the SpendHelper. */ calculateFee(): bigint { - const gas = dimensionsToGas(this.complexity, this.weights); + this.consolidateOutputs(); + + const gas = dimensionsToGas(this.getComplexity(), this.weights); return gas * this.gasPrice; } + calculateFeeWithTemporaryOutputComplexity( + transferableOutput: TransferableOutput, + ): bigint { + const oldOutputComplexity = this.outputComplexity; + this.addOutputComplexity(transferableOutput); + + const fee = this.calculateFee(); + + this.outputComplexity = oldOutputComplexity; + + return fee; + } + + hasChangeOutput(assetId: string, outputOwners: OutputOwners): boolean { + return this.changeOutputs.some( + (output) => + output.assetId.value() === assetId && + isTransferOut(output) && + output.outputOwners.equals(outputOwners), + ); + } + /** * Verifies that all assets have been consumed. * @@ -241,10 +294,13 @@ export class SpendHelper { */ getInputsOutputs(): { changeOutputs: readonly TransferableOutput[]; + fee: bigint; inputs: readonly TransferableInput[]; inputUTXOs: readonly Utxo[]; stakeOutputs: readonly TransferableOutput[]; } { + const fee = this.calculateFee(); + const sortedInputs = [...this.inputs].sort(TransferableInput.compare); const sortedChangeOutputs = [...this.changeOutputs].sort( compareTransferableOutputs, @@ -255,6 +311,7 @@ export class SpendHelper { return { changeOutputs: sortedChangeOutputs, + fee, inputs: sortedInputs, inputUTXOs: this.inputUTXOs, stakeOutputs: sortedStakeOutputs, diff --git a/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts b/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts index 9df9ad979..cc6f970a8 100644 --- a/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts +++ b/src/vms/utils/calculateSpend/utils/verifySignaturesMatch.ts @@ -1,6 +1,7 @@ import type { MatchOwnerResult } from '../../../../utils/matchOwners'; import { matchOwners } from '../../../../utils/matchOwners'; -import type { TransferOutput } from '../../../../serializable'; +import type { Address, TransferOutput } from '../../../../serializable'; +import type { SpendOptionsRequired } from '../../../common'; export type verifySigMatchItem = Required<{ sigData: MatchOwnerResult; @@ -19,19 +20,20 @@ export const NoSigMatchError = new Error('No addresses match UTXO owners'); * @param fromAddresses the addresses the utxos should belong to * @param options * @returns T[] + * @throws Error */ export function verifySignaturesMatch( set: T[], getTransferOutput: (utxo: T) => TransferOutput, - fromAddresses, - options, -): verifySigMatchItem[] { + fromAddresses: readonly Address[], + options: SpendOptionsRequired, +): readonly verifySigMatchItem[] { const outs = set.reduce((acc, data) => { const out = getTransferOutput(data); const sigData = matchOwners( out.outputOwners, - fromAddresses, + [...fromAddresses], options.minIssuanceTime, ); diff --git a/src/vms/utils/consolidateOutputs.ts b/src/vms/utils/consolidateOutputs.ts index 4f7d6f714..c2150b7f4 100644 --- a/src/vms/utils/consolidateOutputs.ts +++ b/src/vms/utils/consolidateOutputs.ts @@ -46,7 +46,7 @@ const combine = (a: TransferableOutput, b: TransferableOutput) => { }; export const consolidateOutputs = ( - outputs: TransferableOutput[], + outputs: readonly TransferableOutput[], ): TransferableOutput[] => { return consolidate(outputs, canCombine, combine); }; From 7183cfeb51df92665c4e281c6fdfb4206ff11fee Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 19 Sep 2024 11:30:02 -0600 Subject: [PATCH 27/39] feat: opt-in consolidate outputs --- src/vms/pvm/etna-builder/builder.ts | 35 +- .../pvm/etna-builder/experimental-spend.ts | 534 ------------- src/vms/pvm/etna-builder/original-spend.ts | 439 +++++++++++ src/vms/pvm/etna-builder/spend.ts | 716 ++++++++++-------- src/vms/pvm/etna-builder/spendHelper.test.ts | 7 +- src/vms/pvm/etna-builder/spendHelper.ts | 16 +- 6 files changed, 890 insertions(+), 857 deletions(-) delete mode 100644 src/vms/pvm/etna-builder/experimental-spend.ts create mode 100644 src/vms/pvm/etna-builder/original-spend.ts diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 358a81b3b..c9c4e13ef 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -65,7 +65,7 @@ import { getOwnerComplexity, getSignerComplexity, } from '../txs/fee'; -import { spend } from './experimental-spend'; +import { spend, useSpendableLockedUTXOs, useUnlockedUTXOs } from './spend'; const getAddressMaps = ({ inputs, @@ -158,13 +158,15 @@ export const newBaseTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses, + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toBurn, utxos, }, + [useUnlockedUTXOs], context, ); @@ -319,13 +321,14 @@ export const newImportTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: importedAvax, fromAddresses, + initialComplexity: complexity, ownerOverride: OutputOwners.fromNative(toAddresses, locktime, threshold), spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -396,13 +399,14 @@ export const newExportTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses, + initialComplexity: complexity, spendOptions: defaultedOptions, toBurn, utxos, }, + [useUnlockedUTXOs], context, ); @@ -474,12 +478,13 @@ export const newCreateSubnetTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -585,12 +590,13 @@ export const newCreateChainTx: TxBuilderFn = ( const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -678,12 +684,13 @@ export const newAddSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -760,12 +767,13 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); @@ -927,13 +935,15 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toStake, utxos, }, + [useSpendableLockedUTXOs, useUnlockedUTXOs], context, ); @@ -1075,13 +1085,15 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, + shouldConsolidateOutputs: true, spendOptions: defaultedOptions, toStake, utxos, }, + [useSpendableLockedUTXOs, useUnlockedUTXOs], context, ); @@ -1188,12 +1200,13 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< const [error, spendResults] = spend( { - complexity, excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), + initialComplexity: complexity, spendOptions: defaultedOptions, utxos, }, + [useUnlockedUTXOs], context, ); diff --git a/src/vms/pvm/etna-builder/experimental-spend.ts b/src/vms/pvm/etna-builder/experimental-spend.ts deleted file mode 100644 index a17fb900c..000000000 --- a/src/vms/pvm/etna-builder/experimental-spend.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { Address } from '../../../serializable'; -import { - BigIntPr, - OutputOwners, - TransferInput, - TransferableInput, - TransferableOutput, - TransferOutput, - Id, -} from '../../../serializable'; -import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; -import type { SpendOptions } from '../../common'; -import type { Dimensions } from '../../common/fees/dimensions'; -import type { Context } from '../../context'; -import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; -import { SpendHelper } from './spendHelper'; - -type SpendResult = Readonly<{ - /** - * The consolidated and sorted change outputs. - */ - changeOutputs: readonly TransferableOutput[]; - /** - * The total calculated fee for the transaction. - */ - fee: bigint; - /** - * The sorted inputs. - */ - inputs: readonly TransferableInput[]; - /** - * The UTXOs that were used as inputs. - */ - inputUTXOs: readonly Utxo[]; - /** - * The consolidated and sorted staked outputs. - */ - stakeOutputs: readonly TransferableOutput[]; -}>; - -export type SpendProps = Readonly<{ - /** - * The initial complexity of the transaction. - */ - complexity: Dimensions; - /** - * The extra AVAX that spend can produce in - * the change outputs in addition to the consumed and not burned AVAX. - */ - excessAVAX?: bigint; - /** - * List of Addresses that are used for selecting which UTXOs are signable. - */ - fromAddresses: readonly Address[]; - /** - * Optionally specifies the output owners to use for the unlocked - * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined` or `null`, the default change owner is used. - * - * Used in ImportTx. - */ - ownerOverride?: OutputOwners | null; - spendOptions: Required; - /** - * Maps `assetID` to the amount of the asset to spend without - * producing an output. This is typically used for fees. - * However, it can also be used to consume some of an asset that - * will be produced in separate outputs, such as ExportedOutputs. - * - * Only unlocked UTXOs are able to be burned here. - */ - toBurn?: Map; - /** - * Maps `assetID` to the amount of the asset to spend and place info - * the staked outputs. First locked UTXOs are attempted to be used for - * these funds, and then unlocked UTXOs will be attempted to be used. - * There is no preferential ordering on the unlock times. - */ - toStake?: Map; - /** - * List of UTXOs that are available to be spent. - */ - utxos: readonly Utxo[]; -}>; - -type SpendReducerState = Readonly>; - -type SpendReducerFunction = ( - state: SpendReducerState, - spendHelper: SpendHelper, - context: Context, -) => SpendReducerState; - -const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { - const verifyError = spendHelper.verifyAssetsConsumed(); - - if (verifyError) { - throw verifyError; - } - - return state; -}; - -export const IncorrectStakeableLockOutError = new Error( - 'StakeableLockOut transferOut must be a TransferOutput.', -); - -export const useSpendableLockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter((utxo): utxo is Utxo> => { - // 1a. Ensure UTXO output is a StakeableLockOut. - if (!isStakeableLockOut(utxo.output)) { - return false; - } - - // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { - return false; - } - - // 1c. Ensure there are funds to stake. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { - return false; - } - - // 1d. Ensure transferOut is a TransferOutput. - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return true; - }); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Do all the logic for spending based on the UTXOs. - for (const { sigData, data: utxo } of verifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. - if (remainingAmountToStake === 0n) { - continue; - } - - // 3b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - // StakeableLockOut - new BigIntPr(utxoInfo.stakeableLocktime), - TransferInput.fromNative( - // TransferOutput - utxoInfo.amount, - sigData.sigIndicies, - ), - ), - ), - ); - - // 3c. Consume the locked asset and get the remaining amount. - const remainingAmount = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 3d. Add the stake output. - spendHelper.addStakedOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - - // 3e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - } - } - - // 4. Add all remaining stake amounts assuming they are unlocked. - for (const [assetId, amount] of state.toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - state.spendOptions.changeAddresses, - ), - ); - } - - return state; -}; - -export const useUnlockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = - state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter( - ( - utxo, - ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; - } - - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return ( - utxo.output.getLocktime() < state.spendOptions.minIssuanceTime - ); - } - - return false; - }, - ); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => - isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. - const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = - verifiedUsableUTXOs.reduce( - (result, { sigData, data: utxo }) => { - if (utxo.assetId.value() === context.avaxAssetID) { - return [result[0], [...result[1], { sigData, data: utxo }]]; - } - - return [[...result[0], { sigData, data: utxo }], result[1]]; - }, - [[], []] as [ - other: typeof verifiedUsableUTXOs, - avax: typeof verifiedUsableUTXOs, - ], - ); - - // 4. Handle all the non-AVAX asset UTXOs first. - for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToBurn: bigint = - state.toBurn.get(utxoInfo.assetId) ?? 0n; - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. - if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { - continue; - } - - // 4b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - // 4c. Consume the asset and get the remaining amount. - const remainingAmount = spendHelper.consumeAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 4d. If "amountToStake" is greater than 0, add the stake output. - // TODO: Implement or determine if needed. - - // 4e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(remainingAmount), - OutputOwners.fromNative( - state.spendOptions.changeAddresses, - 0n, - 1, - ), - ), - ), - ), - ); - } - } - - // 5. Handle AVAX asset UTXOs last to account for fees. - let excessAVAX = state.excessAVAX; - let clearOwnerOverride = false; - for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - const remainingAmount = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); - - excessAVAX += remainingAmount; - - // The ownerOverride is no longer needed. Clear it. - clearOwnerOverride = true; - } - - return { - ...state, - excessAVAX, - ownerOverride: clearOwnerOverride ? null : state.ownerOverride, - }; -}; - -export const handleFee: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - const requiredFee = spendHelper.calculateFee(); - - if (state.excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - state.excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, - ); - } - - // No need to add a change output. - if (state.excessAVAX === requiredFee) { - return state; - } - - // Use the change owner override if it exists, otherwise use the default change owner. - // This is used for import transactions. - const changeOwners = - state.ownerOverride || - OutputOwners.fromNative(state.spendOptions.changeAddresses); - - // TODO: Clean-up if this is no longer needed. - // Additionally, no need for public .addOutputComplexity(). - // - // Pre-consolidation code. - // - // spendHelper.addOutputComplexity( - // new TransferableOutput( - // Id.fromString(context.avaxAssetID), - // new TransferOutput(new BigIntPr(0n), changeOwners), - // ), - // ); - // - // Recalculate the fee with the change output. - // const requiredFeeWithChange = spendHelper.calculateFee(); - - // Calculate the fee with a temporary output complexity if a change output is needed. - const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( - context.avaxAssetID, - changeOwners, - ) - ? requiredFee - : spendHelper.calculateFeeWithTemporaryOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); - - // Add a change output if needed. - if (state.excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(state.excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } - - return state; -}; - -/** - * Processes the spending of assets, including burning and staking, from a list of UTXOs. - * - * @param {SpendProps} props - The properties required to execute the spend operation. - * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. - * @param {Context} context - The context in which the spend operation is executed. - * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, - * and the second element is either the result of the spend operation or null. - */ -export const spend = ( - { - complexity, - excessAVAX: _excessAVAX = 0n, - fromAddresses, - ownerOverride, - spendOptions, - toBurn = new Map(), - toStake = new Map(), - utxos, - }: SpendProps, - // spendReducers: readonly SpendReducerFunction[], - context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - try { - const changeOwners = - ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - const excessAVAX: bigint = _excessAVAX; - - const spendHelper = new SpendHelper({ - changeOutputs: [], - complexity, - gasPrice: context.gasPrice, - inputs: [], - stakeOutputs: [], - toBurn, - toStake, - weights: context.complexityWeights, - }); - - const initialState: SpendReducerState = { - complexity, - excessAVAX, - fromAddresses, - ownerOverride: changeOwners, - spendOptions, - toBurn, - toStake, - utxos, - }; - - const spendReducerFunctions: readonly SpendReducerFunction[] = [ - // ...spendReducers, - useSpendableLockedUTXOs, - useUnlockedUTXOs, - verifyAssetsConsumed, - handleFee, - // Consolidation and sorting happens in the SpendHelper. - ]; - - // Run all the spend calculation reducer logic. - spendReducerFunctions.reduce((state, next) => { - return next(state, spendHelper, context); - }, initialState); - - return [null, spendHelper.getInputsOutputs()]; - } catch (error) { - return [ - error instanceof Error - ? error - : new Error('An unexpected error occurred during spend calculation'), - null, - ]; - } -}; diff --git a/src/vms/pvm/etna-builder/original-spend.ts b/src/vms/pvm/etna-builder/original-spend.ts new file mode 100644 index 000000000..8d10d0881 --- /dev/null +++ b/src/vms/pvm/etna-builder/original-spend.ts @@ -0,0 +1,439 @@ +// TODO: Delete this file once we are done referencing it. + +import type { Address } from '../../../serializable'; +import { + BigIntPr, + Id, + Input, + OutputOwners, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; +import { getUtxoInfo } from '../../../utils'; +import { matchOwners } from '../../../utils/matchOwners'; +import type { SpendOptions } from '../../common'; +import type { Dimensions } from '../../common/fees/dimensions'; +import type { Context } from '../../context'; +import { SpendHelper } from './spendHelper'; + +/** + * @internal + * + * Separates the provided UTXOs into two lists: + * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. + * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. + * + * @param utxos {readonly Utxo[]} + * @param minIssuanceTime {bigint} + * + * @returns Object containing two lists of UTXOs. + */ +export const splitByLocktime = ( + utxos: readonly Utxo[], + minIssuanceTime: bigint, +): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { + const locked: Utxo[] = []; + const unlocked: Utxo[] = []; + + for (const utxo of utxos) { + let utxoOwnersLocktime: bigint; + + // TODO: Remove this try catch in the future in favor of + // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs + try { + utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; + } catch (error) { + // If we can't get the locktime, we can't spend the UTXO. + // TODO: Is this the right thing to do? + // This was necessary to get tests working with testUtxos(). + continue; + } + + if (minIssuanceTime < utxoOwnersLocktime) { + locked.push(utxo); + } else { + unlocked.push(utxo); + } + } + + return { locked, unlocked }; +}; + +/** + * @internal + * + * Separates the provided UTXOs into two lists: + * - `other` contains UTXOs that have an asset ID different from `assetId`. + * - `requested` contains UTXOs that have an asset ID equal to `assetId`. + * + * @param utxos {readonly Utxo[]} + * @param assetId {string} + * + * @returns Object containing two lists of UTXOs. + */ +export const splitByAssetId = ( + utxos: readonly Utxo[], + assetId: string, +): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { + const other: Utxo[] = []; + const requested: Utxo[] = []; + + for (const utxo of utxos) { + if (assetId === utxo.assetId.toString()) { + requested.push(utxo); + } else { + other.push(utxo); + } + } + + return { other, requested }; +}; + +type SpendResult = Readonly<{ + changeOutputs: readonly TransferableOutput[]; + inputs: readonly TransferableInput[]; + inputUTXOs: readonly Utxo[]; + stakeOutputs: readonly TransferableOutput[]; +}>; + +export type SpendProps = Readonly<{ + /** + * The initial complexity of the transaction. + */ + complexity: Dimensions; + /** + * The extra AVAX that spend can produce in + * the change outputs in addition to the consumed and not burned AVAX. + */ + excessAVAX?: bigint; + /** + * List of Addresses that are used for selecting which UTXOs are signable. + */ + fromAddresses: readonly Address[]; + /** + * Optionally specifies the output owners to use for the unlocked + * AVAX change output if no additional AVAX was needed to be burned. + * If this value is `undefined`, the default change owner is used. + */ + ownerOverride?: OutputOwners; + spendOptions: Required; + /** + * Maps `assetID` to the amount of the asset to spend without + * producing an output. This is typically used for fees. + * However, it can also be used to consume some of an asset that + * will be produced in separate outputs, such as ExportedOutputs. + * + * Only unlocked UTXOs are able to be burned here. + */ + toBurn?: Map; + /** + * Maps `assetID` to the amount of the asset to spend and place info + * the staked outputs. First locked UTXOs are attempted to be used for + * these funds, and then unlocked UTXOs will be attempted to be used. + * There is no preferential ordering on the unlock times. + */ + toStake?: Map; + /** + * List of UTXOs that are available to be spent. + */ + utxos: readonly Utxo[]; +}>; + +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + complexity, + excessAVAX: _excessAVAX = 0n, + fromAddresses, + ownerOverride, + spendOptions, + toBurn = new Map(), + toStake = new Map(), + utxos, + }: SpendProps, + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + let changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + let excessAVAX: bigint = _excessAVAX; + + const spendHelper = new SpendHelper({ + changeOutputs: [], + initialComplexity: complexity, + gasPrice: context.gasPrice, + inputs: [], + shouldConsolidateOutputs: false, + stakeOutputs: [], + toBurn, + toStake, + weights: context.complexityWeights, + }); + + const utxosByLocktime = splitByLocktime( + utxos, + spendOptions.minIssuanceTime, + ); + + for (const utxo of utxosByLocktime.locked) { + if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { + continue; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ), + ); + + const excess = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + spendHelper.addStakedOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - excess), + utxo.getOutputOwners(), + ), + ), + ), + ); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), + ), + ), + ); + } + + // Add all remaining stake amounts assuming unlocked UTXOs + for (const [assetId, amount] of toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + spendOptions.changeAddresses, + ), + ); + } + + // AVAX is handled last to account for fees. + const utxosByAVAXAssetId = splitByAssetId( + utxosByLocktime.unlocked, + context.avaxAssetID, + ); + + for (const utxo of utxosByAVAXAssetId.other) { + const assetId = utxo.assetId.toString(); + + if (!spendHelper.shouldConsumeAsset(assetId)) { + continue; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ); + + const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); + + if (excess === 0n) { + continue; + } + + // This input had extra value, so some of it must be returned as change. + spendHelper.addChangeOutput( + // TODO: Verify this. + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(excess), + OutputOwners.fromNative(spendOptions.changeAddresses), + ), + ), + ); + } + + for (const utxo of utxosByAVAXAssetId.requested) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + [...fromAddresses], + spendOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + continue; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + // TODO: Verify this. + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + new BigIntPr(utxoInfo.amount), + Input.fromNative(inputSigIndices), + ), + ), + ); + + const excess = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); + + excessAVAX += excess; + + // If we need to consume additional AVAX, we should be returning the + // change to the change address. + changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); + } + + // Verify + const verifyError = spendHelper.verifyAssetsConsumed(); + if (verifyError) { + return [verifyError, null]; + } + + const requiredFee = spendHelper.calculateFee(); + + if (excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } + + // NOTE: This logic differs a bit from avalanche go because our classes are immutable. + spendHelper.addOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); + + const requiredFeeWithChange = spendHelper.calculateFee(); + + if (excessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(excessAVAX - requiredFeeWithChange), + changeOwners, + ), + ), + ); + } + + // Sorting happens in the .getInputsOutputs() method. + return [null, spendHelper.getInputsOutputs()]; + } catch (error) { + return [ + new Error('An unexpected error occurred during spend calculation', { + cause: error instanceof Error ? error : undefined, + }), + null, + ]; + } +}; diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index c7fa534dc..4274302fe 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -1,108 +1,46 @@ import type { Address } from '../../../serializable'; import { BigIntPr, - Id, - Input, OutputOwners, TransferInput, - TransferOutput, TransferableInput, TransferableOutput, + TransferOutput, + Id, } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo } from '../../../utils'; -import { matchOwners } from '../../../utils/matchOwners'; +import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; import type { SpendOptions } from '../../common'; import type { Dimensions } from '../../common/fees/dimensions'; import type { Context } from '../../context'; +import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; import { SpendHelper } from './spendHelper'; -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. - * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. - * - * @param utxos {readonly Utxo[]} - * @param minIssuanceTime {bigint} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByLocktime = ( - utxos: readonly Utxo[], - minIssuanceTime: bigint, -): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { - const locked: Utxo[] = []; - const unlocked: Utxo[] = []; - - for (const utxo of utxos) { - let utxoOwnersLocktime: bigint; - - // TODO: Remove this try catch in the future in favor of - // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs - try { - utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; - } catch (error) { - // If we can't get the locktime, we can't spend the UTXO. - // TODO: Is this the right thing to do? - // This was necessary to get tests working with testUtxos(). - continue; - } - - if (minIssuanceTime < utxoOwnersLocktime) { - locked.push(utxo); - } else { - unlocked.push(utxo); - } - } - - return { locked, unlocked }; -}; - -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `other` contains UTXOs that have an asset ID different from `assetId`. - * - `requested` contains UTXOs that have an asset ID equal to `assetId`. - * - * @param utxos {readonly Utxo[]} - * @param assetId {string} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByAssetId = ( - utxos: readonly Utxo[], - assetId: string, -): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { - const other: Utxo[] = []; - const requested: Utxo[] = []; - - for (const utxo of utxos) { - if (assetId === utxo.assetId.toString()) { - requested.push(utxo); - } else { - other.push(utxo); - } - } - - return { other, requested }; -}; - type SpendResult = Readonly<{ + /** + * The consolidated and sorted change outputs. + */ changeOutputs: readonly TransferableOutput[]; + /** + * The total calculated fee for the transaction. + */ + fee: bigint; + /** + * The sorted inputs. + */ inputs: readonly TransferableInput[]; + /** + * The UTXOs that were used as inputs. + */ inputUTXOs: readonly Utxo[]; + /** + * The consolidated and sorted staked outputs. + */ stakeOutputs: readonly TransferableOutput[]; }>; export type SpendProps = Readonly<{ - /** - * The initial complexity of the transaction. - */ - complexity: Dimensions; /** * The extra AVAX that spend can produce in * the change outputs in addition to the consumed and not burned AVAX. @@ -112,12 +50,24 @@ export type SpendProps = Readonly<{ * List of Addresses that are used for selecting which UTXOs are signable. */ fromAddresses: readonly Address[]; + /** + * The initial complexity of the transaction. + */ + initialComplexity: Dimensions; /** * Optionally specifies the output owners to use for the unlocked * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined`, the default change owner is used. + * If this value is `undefined` or `null`, the default change owner is used. + * + * Used in ImportTx. */ - ownerOverride?: OutputOwners; + ownerOverride?: OutputOwners | null; + /** + * Whether to consolidate outputs. + * + * @default false + */ + shouldConsolidateOutputs?: boolean; spendOptions: Required; /** * Maps `assetID` to the amount of the asset to spend without @@ -141,295 +91,453 @@ export type SpendProps = Readonly<{ utxos: readonly Utxo[]; }>; -/** - * Processes the spending of assets, including burning and staking, from a list of UTXOs. - * - * @param {SpendProps} props - The properties required to execute the spend operation. - * @param {Context} context - The context in which the spend operation is executed. - * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, - * and the second element is either the result of the spend operation or null. - */ -export const spend = ( - { - complexity, - excessAVAX: _excessAVAX = 0n, - fromAddresses, - ownerOverride, - spendOptions, - toBurn = new Map(), - toStake = new Map(), - utxos, - }: SpendProps, +type SpendReducerState = Readonly< + Required> +>; + +type SpendReducerFunction = ( + state: SpendReducerState, + spendHelper: SpendHelper, context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - try { - let changeOwners = - ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - let excessAVAX: bigint = _excessAVAX; +) => SpendReducerState; - const spendHelper = new SpendHelper({ - changeOutputs: [], - complexity, - gasPrice: context.gasPrice, - inputs: [], - stakeOutputs: [], - toBurn, - toStake, - weights: context.complexityWeights, - }); +const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { + const verifyError = spendHelper.verifyAssetsConsumed(); - const utxosByLocktime = splitByLocktime( - utxos, - spendOptions.minIssuanceTime, - ); + if (verifyError) { + throw verifyError; + } - for (const utxo of utxosByLocktime.locked) { - if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { - continue; + return state; +}; + +export const IncorrectStakeableLockOutError = new Error( + 'StakeableLockOut transferOut must be a TransferOutput.', +); + +export const useSpendableLockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter((utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; } - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; + // 1c. Ensure there are funds to stake. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; } - const utxoInfo = getUtxoInfo(utxo); + // 1d. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), + return true; + }); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Do all the logic for spending based on the UTXOs. + for (const { sigData, data: utxo } of verifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. + if (remainingAmountToStake === 0n) { + continue; + } + + // 3b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + // StakeableLockOut + new BigIntPr(utxoInfo.stakeableLocktime), + TransferInput.fromNative( + // TransferOutput + utxoInfo.amount, + sigData.sigIndicies, ), ), - ); + ), + ); - const excess = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); + // 3c. Consume the locked asset and get the remaining amount. + const remainingAmount = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); - spendHelper.addStakedOutput( - // TODO: Verify this. - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - excess), - utxo.getOutputOwners(), - ), + // 3d. Add the stake output. + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - remainingAmount), + utxo.getOutputOwners(), ), ), - ); - - if (excess === 0n) { - continue; - } + ), + ); - // This input had extra value, so some of it must be returned as change. + // 3e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { spendHelper.addChangeOutput( - // TODO: Verify this. new TransferableOutput( utxo.assetId, new StakeableLockOut( new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), + new TransferOutput( + new BigIntPr(remainingAmount), + utxo.getOutputOwners(), + ), ), ), ); } + } - // Add all remaining stake amounts assuming unlocked UTXOs - for (const [assetId, amount] of toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - spendOptions.changeAddresses, - ), - ); + // 4. Add all remaining stake amounts assuming they are unlocked. + for (const [assetId, amount] of state.toStake) { + if (amount === 0n) { + continue; } - // AVAX is handled last to account for fees. - const utxosByAVAXAssetId = splitByAssetId( - utxosByLocktime.unlocked, - context.avaxAssetID, + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + state.spendOptions.changeAddresses, + ), ); + } - for (const utxo of utxosByAVAXAssetId.other) { - const assetId = utxo.assetId.toString(); + return state; +}; - if (!spendHelper.shouldConsumeAsset(assetId)) { - continue; - } +export const useUnlockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = + state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter( + ( + utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return ( + utxo.output.getLocktime() < state.spendOptions.minIssuanceTime + ); + } + + return false; + }, + ); - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => + isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. + const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = + verifiedUsableUTXOs.reduce( + (result, { sigData, data: utxo }) => { + if (utxo.assetId.value() === context.avaxAssetID) { + return [result[0], [...result[1], { sigData, data: utxo }]]; + } + + return [[...result[0], { sigData, data: utxo }], result[1]]; + }, + [[], []] as [ + other: typeof verifiedUsableUTXOs, + avax: typeof verifiedUsableUTXOs, + ], + ); - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } + // 4. Handle all the non-AVAX asset UTXOs first. + for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToBurn: bigint = + state.toBurn.get(utxoInfo.assetId) ?? 0n; + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; - const utxoInfo = getUtxoInfo(utxo); + // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. + if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { + continue; + } - spendHelper.addInput( - utxo, - // TODO: Verify this. - // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); + // 4b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); - const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); + // 4c. Consume the asset and get the remaining amount. + const remainingAmount = spendHelper.consumeAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); - if (excess === 0n) { - continue; - } + // 4d. If "amountToStake" is greater than 0, add the stake output. + // TODO: Implement or determine if needed. - // This input had extra value, so some of it must be returned as change. + // 4e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { spendHelper.addChangeOutput( - // TODO: Verify this. new TransferableOutput( utxo.assetId, - new TransferOutput( - new BigIntPr(excess), - OutputOwners.fromNative(spendOptions.changeAddresses), + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(remainingAmount), + OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ), + ), ), ), ); } + } - for (const utxo of utxosByAVAXAssetId.requested) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } + // 5. Handle AVAX asset UTXOs last to account for fees. + let excessAVAX = state.excessAVAX; + let clearOwnerOverride = false; + for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { + const requiredFee = spendHelper.calculateFee(); - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } + const utxoInfo = getUtxoInfo(utxo); - const utxoInfo = getUtxoInfo(utxo); + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); + const remainingAmount = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); - const excess = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); + excessAVAX += remainingAmount; - excessAVAX += excess; + // The ownerOverride is no longer needed. Clear it. + clearOwnerOverride = true; + } - // If we need to consume additional AVAX, we should be returning the - // change to the change address. - changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); - } + return { + ...state, + excessAVAX, + ownerOverride: clearOwnerOverride ? null : state.ownerOverride, + }; +}; - // Verify - const verifyError = spendHelper.verifyAssetsConsumed(); - if (verifyError) { - return [verifyError, null]; - } +export const handleFee: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + const requiredFee = spendHelper.calculateFee(); + + if (state.excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - state.excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } - const requiredFee = spendHelper.calculateFee(); + // No need to add a change output. + if (state.excessAVAX === requiredFee) { + return state; + } - if (excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, + // Use the change owner override if it exists, otherwise use the default change owner. + // This is used for import transactions. + const changeOwners = + state.ownerOverride || + OutputOwners.fromNative(state.spendOptions.changeAddresses); + + // TODO: Clean-up if this is no longer needed. + // Additionally, no need for public .addOutputComplexity(). + // + // Pre-consolidation code. + // + // spendHelper.addOutputComplexity( + // new TransferableOutput( + // Id.fromString(context.avaxAssetID), + // new TransferOutput(new BigIntPr(0n), changeOwners), + // ), + // ); + // + // Recalculate the fee with the change output. + // const requiredFeeWithChange = spendHelper.calculateFee(); + + // Calculate the fee with a temporary output complexity if a change output is needed. + const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( + context.avaxAssetID, + changeOwners, + ) + ? requiredFee + : spendHelper.calculateFeeWithTemporaryOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), ); - } - // NOTE: This logic differs a bit from avalanche go because our classes are immutable. - spendHelper.addOutputComplexity( + // Add a change output if needed. + if (state.excessAVAX > requiredFeeWithChange) { + // It is worth adding the change output. + spendHelper.addChangeOutput( new TransferableOutput( Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFeeWithChange), + changeOwners, + ), ), ); + } - const requiredFeeWithChange = spendHelper.calculateFee(); + return state; +}; - if (excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } +/** + * Processes the spending of assets, including burning and staking, from a list of UTXOs. + * + * @param {SpendProps} props - The properties required to execute the spend operation. + * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. + * @param {Context} context - The context in which the spend operation is executed. + * + * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * and the second element is either the result of the spend operation or null. + */ +export const spend = ( + { + excessAVAX: _excessAVAX = 0n, + fromAddresses, + initialComplexity, + ownerOverride, + shouldConsolidateOutputs = false, + spendOptions, + toBurn = new Map(), + toStake = new Map(), + utxos, + }: SpendProps, + spendReducers: readonly SpendReducerFunction[], + context: Context, +): + | [error: null, inputsAndOutputs: SpendResult] + | [error: Error, inputsAndOutputs: null] => { + try { + const changeOwners = + ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); + const excessAVAX: bigint = _excessAVAX; + + const spendHelper = new SpendHelper({ + changeOutputs: [], + gasPrice: context.gasPrice, + initialComplexity, + inputs: [], + shouldConsolidateOutputs, + stakeOutputs: [], + toBurn, + toStake, + weights: context.complexityWeights, + }); + + const initialState: SpendReducerState = { + excessAVAX, + initialComplexity, + fromAddresses, + ownerOverride: changeOwners, + spendOptions, + toBurn, + toStake, + utxos, + }; + + const spendReducerFunctions: readonly SpendReducerFunction[] = [ + ...spendReducers, + // useSpendableLockedUTXOs, + // useUnlockedUTXOs, + verifyAssetsConsumed, + handleFee, + // Consolidation and sorting happens in the SpendHelper. + ]; + + // Run all the spend calculation reducer logic. + spendReducerFunctions.reduce((state, next) => { + return next(state, spendHelper, context); + }, initialState); - // Sorting happens in the .getInputsOutputs() method. return [null, spendHelper.getInputsOutputs()]; } catch (error) { return [ - new Error('An unexpected error occurred during spend calculation', { - cause: error instanceof Error ? error : undefined, - }), + error instanceof Error + ? error + : new Error('An unexpected error occurred during spend calculation'), null, ]; } diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 820cb001c..abfbe68f8 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -16,9 +16,10 @@ const DEFAULT_WEIGHTS = createDimensions(1, 2, 3, 4); const DEFAULT_PROPS: SpendHelperProps = { changeOutputs: [], - complexity: createDimensions(1, 1, 1, 1), gasPrice: DEFAULT_GAS_PRICE, + initialComplexity: createDimensions(1, 1, 1, 1), inputs: [], + shouldConsolidateOutputs: false, stakeOutputs: [], toBurn: new Map(), toStake: new Map(), @@ -35,7 +36,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(results.changeOutputs).toEqual([]); expect(results.fee).toBe( - dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + dimensionsToGas(DEFAULT_PROPS.initialComplexity, DEFAULT_WEIGHTS) * DEFAULT_GAS_PRICE, ); expect(results.inputs).toEqual([]); @@ -49,7 +50,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [], fee: - dimensionsToGas(DEFAULT_PROPS.complexity, DEFAULT_WEIGHTS) * + dimensionsToGas(DEFAULT_PROPS.initialComplexity, DEFAULT_WEIGHTS) * DEFAULT_GAS_PRICE, inputs: [], inputUTXOs: [], diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 6a844f323..81bc3b084 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -15,9 +15,10 @@ import { getInputComplexity, getOutputComplexity } from '../txs/fee'; export interface SpendHelperProps { changeOutputs: readonly TransferableOutput[]; - complexity: Dimensions; gasPrice: bigint; + initialComplexity: Dimensions; inputs: readonly TransferableInput[]; + shouldConsolidateOutputs: boolean; stakeOutputs: readonly TransferableOutput[]; toBurn: Map; toStake: Map; @@ -33,6 +34,7 @@ export interface SpendHelperProps { export class SpendHelper { private readonly gasPrice: bigint; private readonly initialComplexity: Dimensions; + private readonly shouldConsolidateOutputs: boolean; private readonly toBurn: Map; private readonly toStake: Map; private readonly weights: Dimensions; @@ -47,16 +49,18 @@ export class SpendHelper { constructor({ changeOutputs, - complexity, gasPrice, + initialComplexity, inputs, + shouldConsolidateOutputs, stakeOutputs, toBurn, toStake, weights, }: SpendHelperProps) { this.gasPrice = gasPrice; - this.initialComplexity = complexity; + this.initialComplexity = initialComplexity; + this.shouldConsolidateOutputs = shouldConsolidateOutputs; this.toBurn = toBurn; this.toStake = toStake; this.weights = weights; @@ -141,8 +145,10 @@ export class SpendHelper { } private consolidateOutputs(): void { - this.changeOutputs = consolidateOutputs(this.changeOutputs); - this.stakeOutputs = consolidateOutputs(this.stakeOutputs); + if (this.shouldConsolidateOutputs) { + this.changeOutputs = consolidateOutputs(this.changeOutputs); + this.stakeOutputs = consolidateOutputs(this.stakeOutputs); + } } /** From 4a29afcce37af5f88b02d999b17cc739380ce1a4 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 20 Sep 2024 16:04:07 -0600 Subject: [PATCH 28/39] feat: spend reducers and tests --- jest.config.ts | 3 + src/vms/pvm/etna-builder/builder.ts | 63 +-- .../pvm/etna-builder/spend-reducers.test.ts | 138 ++++++ src/vms/pvm/etna-builder/spend-reducers.ts | 412 +++++++++++++++++ src/vms/pvm/etna-builder/spend.test.ts | 117 ++++- src/vms/pvm/etna-builder/spend.ts | 415 +----------------- src/vms/pvm/etna-builder/spendHelper.test.ts | 90 ++++ src/vms/pvm/etna-builder/spendHelper.ts | 15 +- 8 files changed, 800 insertions(+), 453 deletions(-) create mode 100644 src/vms/pvm/etna-builder/spend-reducers.test.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers.ts diff --git a/jest.config.ts b/jest.config.ts index 6d70b66db..fc32ddd1f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,4 +15,7 @@ module.exports = { testEnvironment: 'node', coverageProvider: 'v8', extensionsToTreatAsEsm: ['.ts'], + // Experimental to fix issues with BigInt serialization + // See: https://jestjs.io/docs/configuration#workerthreads + workerThreads: true, }; diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index c9c4e13ef..07cffdebe 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -65,7 +65,8 @@ import { getOwnerComplexity, getSignerComplexity, } from '../txs/fee'; -import { spend, useSpendableLockedUTXOs, useUnlockedUTXOs } from './spend'; +import { spend } from './spend'; +import { useSpendableLockedUTXOs, useUnlockedUTXOs } from './spend-reducers'; const getAddressMaps = ({ inputs, @@ -156,7 +157,7 @@ export const newBaseTx: TxBuilderFn = ( outputComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses, @@ -170,10 +171,6 @@ export const newBaseTx: TxBuilderFn = ( context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -319,7 +316,7 @@ export const newImportTx: TxBuilderFn = ( outputComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: importedAvax, fromAddresses, @@ -332,10 +329,6 @@ export const newImportTx: TxBuilderFn = ( context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; return new UnsignedTx( @@ -397,7 +390,7 @@ export const newExportTx: TxBuilderFn = ( outputComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses, @@ -410,10 +403,6 @@ export const newExportTx: TxBuilderFn = ( context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -476,7 +465,7 @@ export const newCreateSubnetTx: TxBuilderFn = ( ownerComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -488,10 +477,6 @@ export const newCreateSubnetTx: TxBuilderFn = ( context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -588,7 +573,7 @@ export const newCreateChainTx: TxBuilderFn = ( authComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -600,10 +585,6 @@ export const newCreateChainTx: TxBuilderFn = ( context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -682,7 +663,7 @@ export const newAddSubnetValidatorTx: TxBuilderFn< authComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -694,10 +675,6 @@ export const newAddSubnetValidatorTx: TxBuilderFn< context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -765,7 +742,7 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< authComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -777,10 +754,6 @@ export const newRemoveSubnetValidatorTx: TxBuilderFn< context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -933,7 +906,7 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< delegatorOwnerComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -947,10 +920,6 @@ export const newAddPermissionlessValidatorTx: TxBuilderFn< context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -1083,7 +1052,7 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< ownerComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -1097,10 +1066,6 @@ export const newAddPermissionlessDelegatorTx: TxBuilderFn< context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = spendResults; const addressMaps = getAddressMaps({ inputs, @@ -1198,7 +1163,7 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< ownerComplexity, ); - const [error, spendResults] = spend( + const spendResults = spend( { excessAVAX: 0n, fromAddresses: addressesFromBytes(fromAddressesBytes), @@ -1210,10 +1175,6 @@ export const newTransferSubnetOwnershipTx: TxBuilderFn< context, ); - if (error) { - throw error; - } - const { changeOutputs, inputs, inputUTXOs } = spendResults; const addressMaps = getAddressMaps({ inputs, diff --git a/src/vms/pvm/etna-builder/spend-reducers.test.ts b/src/vms/pvm/etna-builder/spend-reducers.test.ts new file mode 100644 index 000000000..8cdd6b3b0 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers.test.ts @@ -0,0 +1,138 @@ +import { jest } from '@jest/globals'; + +import { testContext } from '../../../fixtures/context'; +import { Address, OutputOwners } from '../../../serializable'; +import { defaultSpendOptions } from '../../common/defaultSpendOptions'; +import { createDimensions } from '../../common/fees/dimensions'; +import type { SpendHelperProps } from './spendHelper'; +import { SpendHelper } from './spendHelper'; +import type { SpendReducerState } from './spend-reducers'; +import { handleFeeAndChange } from './spend-reducers'; + +const CHANGE_ADDRESS = Address.fromString( + 'P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg', +); +const CHANGE_OWNERS: OutputOwners = OutputOwners.fromNative([ + CHANGE_ADDRESS.toBytes(), +]); + +const getInitialReducerState = ( + state: Partial = {}, +): SpendReducerState => ({ + excessAVAX: 0n, + initialComplexity: createDimensions(1, 1, 1, 1), + fromAddresses: [CHANGE_ADDRESS], + ownerOverride: null, + spendOptions: defaultSpendOptions( + state?.fromAddresses?.map((address) => address.toBytes()) ?? [ + CHANGE_ADDRESS.toBytes(), + ], + ), + toBurn: new Map(), + toStake: new Map(), + utxos: [], + ...state, +}); + +const getSpendHelper = ({ + initialComplexity = createDimensions(1, 1, 1, 1), + shouldConsolidateOutputs = false, + toBurn = new Map(), + toStake = new Map(), +}: Partial< + Pick< + SpendHelperProps, + 'initialComplexity' | 'shouldConsolidateOutputs' | 'toBurn' | 'toStake' + > +> = {}) => { + return new SpendHelper({ + changeOutputs: [], + gasPrice: testContext.gasPrice, + initialComplexity, + inputs: [], + shouldConsolidateOutputs, + stakeOutputs: [], + toBurn, + toStake, + weights: testContext.complexityWeights, + }); +}; + +describe('./src/vms/pvm/etna-builder/spend-reducers.test.ts', () => { + describe('handleFeeAndChange', () => { + test('throws an error if excessAVAX is less than the required fee', () => { + expect(() => + handleFeeAndChange( + getInitialReducerState(), + getSpendHelper(), + testContext, + ), + ).toThrow( + `Insufficient funds: provided UTXOs need 4 more nAVAX (asset id: ${testContext.avaxAssetID})`, + ); + }); + + test('returns original state if excessAVAX equals the required fee', () => { + const state = getInitialReducerState({ excessAVAX: 4n }); + const spendHelper = getSpendHelper(); + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( + spendHelper, + 'calculateFeeWithTemporaryOutputComplexity', + ); + + expect(handleFeeAndChange(state, getSpendHelper(), testContext)).toEqual( + state, + ); + expect( + calculateFeeWithTemporaryOutputComplexitySpy, + ).not.toHaveBeenCalled(); + expect(addChangeOutputSpy).not.toHaveBeenCalled(); + }); + + test('adds a change output if excessAVAX is greater than the required fee', () => { + const excessAVAX = 1_000n; + const state = getInitialReducerState({ + excessAVAX, + }); + const spendHelper = getSpendHelper(); + + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( + spendHelper, + 'calculateFeeWithTemporaryOutputComplexity', + ); + + expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual({ + ...state, + excessAVAX, + }); + expect( + calculateFeeWithTemporaryOutputComplexitySpy, + ).toHaveBeenCalledTimes(1); + expect(addChangeOutputSpy).toHaveBeenCalledTimes(1); + + expect( + spendHelper.hasChangeOutput(testContext.avaxAssetID, CHANGE_OWNERS), + ).toBe(true); + + expect(spendHelper.getInputsOutputs().changeOutputs).toHaveLength(1); + }); + + test('does not add change output if fee with temporary output complexity and excessAVAX are equal or less', () => { + const excessAVAX = 5n; + const state = getInitialReducerState({ + excessAVAX, + }); + const spendHelper = getSpendHelper(); + + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + + expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual( + state, + ); + + expect(addChangeOutputSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/vms/pvm/etna-builder/spend-reducers.ts b/src/vms/pvm/etna-builder/spend-reducers.ts new file mode 100644 index 000000000..9d57a80e0 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers.ts @@ -0,0 +1,412 @@ +import { + BigIntPr, + Id, + OutputOwners, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../serializable'; +import type { Utxo } from '../../../serializable/avax/utxo'; +import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; +import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; +import type { Context } from '../../context'; +import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; +import type { SpendProps } from './spend'; +import type { SpendHelper } from './spendHelper'; + +export type SpendReducerState = Readonly< + Required> +>; + +export type SpendReducerFunction = ( + state: SpendReducerState, + spendHelper: SpendHelper, + context: Context, +) => SpendReducerState; + +export const verifyAssetsConsumed: SpendReducerFunction = ( + state, + spendHelper, +) => { + const verifyError = spendHelper.verifyAssetsConsumed(); + + if (verifyError) { + throw verifyError; + } + + return state; +}; + +export const IncorrectStakeableLockOutError = new Error( + 'StakeableLockOut transferOut must be a TransferOutput.', +); + +export const useSpendableLockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter((utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; + } + + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } + + // 1c. Ensure there are funds to stake. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; + } + + // 1d. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return true; + }); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Do all the logic for spending based on the UTXOs. + for (const { sigData, data: utxo } of verifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. + if (remainingAmountToStake === 0n) { + continue; + } + + // 3b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + // StakeableLockOut + new BigIntPr(utxoInfo.stakeableLocktime), + TransferInput.fromNative( + // TransferOutput + utxoInfo.amount, + sigData.sigIndicies, + ), + ), + ), + ); + + // 3c. Consume the locked asset and get the remaining amount. + const remainingAmount = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 3d. Add the stake output. + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + + // 3e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + } + } + + // 4. Add all remaining stake amounts assuming they are unlocked. + for (const [assetId, amount] of state.toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + state.spendOptions.changeAddresses, + ), + ); + } + + return state; +}; + +export const useUnlockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = + state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter( + ( + utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return ( + utxo.output.getLocktime() < state.spendOptions.minIssuanceTime + ); + } + + return false; + }, + ); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => + isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. + const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = + verifiedUsableUTXOs.reduce( + (result, { sigData, data: utxo }) => { + if (utxo.assetId.value() === context.avaxAssetID) { + return [result[0], [...result[1], { sigData, data: utxo }]]; + } + + return [[...result[0], { sigData, data: utxo }], result[1]]; + }, + [[], []] as [ + other: typeof verifiedUsableUTXOs, + avax: typeof verifiedUsableUTXOs, + ], + ); + + // 4. Handle all the non-AVAX asset UTXOs first. + for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToBurn: bigint = + state.toBurn.get(utxoInfo.assetId) ?? 0n; + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. + if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { + continue; + } + + // 4b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + // 4c. Consume the asset and get the remaining amount. + const remainingAmount = spendHelper.consumeAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 4d. If "amountToStake" is greater than 0, add the stake output. + // TODO: Implement or determine if needed. + + // 4e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(remainingAmount), + OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ), + ), + ), + ), + ); + } + } + + // 5. Handle AVAX asset UTXOs last to account for fees. + let excessAVAX = state.excessAVAX; + let clearOwnerOverride = false; + for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + const remainingAmount = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); + + excessAVAX += remainingAmount; + + // The ownerOverride is no longer needed. Clear it. + clearOwnerOverride = true; + } + + return { + ...state, + excessAVAX, + ownerOverride: clearOwnerOverride ? null : state.ownerOverride, + }; +}; + +/** + * Determines if the fee can be covered by the excess AVAX. + * + * @returns {boolean} - Whether the excess AVAX exceeds the fee. `true` greater than the fee, `false` if equal. + * @throws {Error} - If the excess AVAX is less than the required fee. + */ +const canPayFeeAndNeedsChange = ( + excessAVAX: bigint, + requiredFee: bigint, + context: Context, +): boolean => { + // Not enough funds to pay the fee. + if (excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } + + // No need to add a change to change output. + // Just burn the fee. + if (excessAVAX === requiredFee) { + return false; + } + + return true; +}; + +export const handleFeeAndChange: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // Use the change owner override if it exists, otherwise use the default change owner. + // This is used on "import" transactions. + const changeOwners = + state.ownerOverride ?? + OutputOwners.fromNative(state.spendOptions.changeAddresses); + + const requiredFee = spendHelper.calculateFee(); + + // Checks for an existing change output that is for the AVAX asset assigned to the change owner. + const hasExistingChangeOutput: boolean = spendHelper.hasChangeOutput( + context.avaxAssetID, + changeOwners, + ); + + if (canPayFeeAndNeedsChange(state.excessAVAX, requiredFee, context)) { + if (hasExistingChangeOutput) { + // Excess exceeds fee, return the change. + // This output will get consolidated with the existing output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFee), + changeOwners, + ), + ), + ); + } else { + // Calculate the fee with a temporary output complexity + // as if we added the change output. + const requiredFeeWithChangeOutput = + spendHelper.calculateFeeWithTemporaryOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); + + // If the excess AVAX is greater than the new fee, add a change output. + // Otherwise, ignore and burn the excess because it can't be returned + // (ie we can't pay the fee to return the excess). + if (state.excessAVAX > requiredFeeWithChangeOutput) { + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFeeWithChangeOutput), + changeOwners, + ), + ), + ); + } + } + } + + return state; +}; diff --git a/src/vms/pvm/etna-builder/spend.test.ts b/src/vms/pvm/etna-builder/spend.test.ts index b83fef0fb..8c88703d6 100644 --- a/src/vms/pvm/etna-builder/spend.test.ts +++ b/src/vms/pvm/etna-builder/spend.test.ts @@ -1,5 +1,118 @@ +import { jest } from '@jest/globals'; + +import { testContext } from '../../../fixtures/context'; +import { Address, OutputOwners } from '../../../serializable'; +import { defaultSpendOptions } from '../../common/defaultSpendOptions'; +import { createDimensions } from '../../common/fees/dimensions'; +import { + verifyAssetsConsumed, + type SpendReducerFunction, + type SpendReducerState, + handleFeeAndChange, +} from './spend-reducers'; +import { spend } from './spend'; + +jest.mock('./spend-reducers', () => ({ + verifyAssetsConsumed: jest.fn((state) => state), + handleFeeAndChange: jest.fn((state) => state), +})); + +const CHANGE_ADDRESS = Address.fromString( + 'P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg', +); +const CHANGE_OWNERS: OutputOwners = OutputOwners.fromNative([ + CHANGE_ADDRESS.toBytes(), +]); + +const getInitialReducerState = ( + state: Partial = {}, +): SpendReducerState => ({ + excessAVAX: 0n, + initialComplexity: createDimensions(1, 1, 1, 1), + fromAddresses: [CHANGE_ADDRESS], + ownerOverride: null, + spendOptions: defaultSpendOptions( + state?.fromAddresses?.map((address) => address.toBytes()) ?? [ + CHANGE_ADDRESS.toBytes(), + ], + ), + toBurn: new Map(), + toStake: new Map(), + utxos: [], + ...state, +}); + describe('./src/vms/pvm/etna-builder/spend.test.ts', () => { - describe('spend', () => { - test.todo('need coverage here'); + // TODO: Enable. + // Test is broken due to mocks not working. Needs investigation. + test.skip('calls spend reducers', () => { + const testReducer = jest.fn((state) => state); + + spend( + getInitialReducerState({ excessAVAX: 1_000n }), + [testReducer], + testContext, + ); + + expect(testReducer).toHaveBeenCalledTimes(1); + expect(verifyAssetsConsumed).toHaveBeenCalledTimes(1); + expect(handleFeeAndChange).toHaveBeenCalledTimes(1); + }); + + test('catches thrown errors and re-throws', () => { + const testReducer = jest.fn(() => { + throw new Error('Test error'); + }); + + expect(() => + spend( + getInitialReducerState({ excessAVAX: 1_000n }), + [testReducer], + testContext, + ), + ).toThrow('Test error'); + }); + + test('catches thrown non-error and throws error', () => { + const testReducer = jest.fn(() => { + throw 'not-an-error'; + }); + + expect(() => + spend( + getInitialReducerState({ excessAVAX: 1_000n }), + [testReducer], + testContext, + ), + ).toThrow('An unexpected error occurred during spend calculation'); + }); + + test('change owners in state should default to change addresses', () => { + expect.assertions(1); + + const initialState = getInitialReducerState({ excessAVAX: 1_000n }); + const testReducer = jest.fn((state) => { + expect(state.ownerOverride).toEqual( + OutputOwners.fromNative(initialState.spendOptions.changeAddresses), + ); + return state; + }); + + spend(initialState, [testReducer], testContext); + }); + + test('change owners in state should be ownerOverride if provided', () => { + expect.assertions(1); + + const initialState = getInitialReducerState({ + excessAVAX: 1_000n, + ownerOverride: CHANGE_OWNERS, + }); + const testReducer = jest.fn((state) => { + expect(state.ownerOverride).toBe(CHANGE_OWNERS); + return state; + }); + + spend(initialState, [testReducer], testContext); }); }); diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index 4274302fe..a68b39c0e 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -1,20 +1,15 @@ -import type { Address } from '../../../serializable'; -import { - BigIntPr, - OutputOwners, - TransferInput, +import type { + Address, TransferableInput, TransferableOutput, - TransferOutput, - Id, } from '../../../serializable'; +import { OutputOwners } from '../../../serializable'; import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; import type { SpendOptions } from '../../common'; import type { Dimensions } from '../../common/fees/dimensions'; import type { Context } from '../../context'; -import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; +import type { SpendReducerFunction, SpendReducerState } from './spend-reducers'; +import { handleFeeAndChange, verifyAssetsConsumed } from './spend-reducers'; import { SpendHelper } from './spendHelper'; type SpendResult = Readonly<{ @@ -91,378 +86,6 @@ export type SpendProps = Readonly<{ utxos: readonly Utxo[]; }>; -type SpendReducerState = Readonly< - Required> ->; - -type SpendReducerFunction = ( - state: SpendReducerState, - spendHelper: SpendHelper, - context: Context, -) => SpendReducerState; - -const verifyAssetsConsumed: SpendReducerFunction = (state, spendHelper) => { - const verifyError = spendHelper.verifyAssetsConsumed(); - - if (verifyError) { - throw verifyError; - } - - return state; -}; - -export const IncorrectStakeableLockOutError = new Error( - 'StakeableLockOut transferOut must be a TransferOutput.', -); - -export const useSpendableLockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter((utxo): utxo is Utxo> => { - // 1a. Ensure UTXO output is a StakeableLockOut. - if (!isStakeableLockOut(utxo.output)) { - return false; - } - - // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { - return false; - } - - // 1c. Ensure there are funds to stake. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { - return false; - } - - // 1d. Ensure transferOut is a TransferOutput. - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return true; - }); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Do all the logic for spending based on the UTXOs. - for (const { sigData, data: utxo } of verifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. - if (remainingAmountToStake === 0n) { - continue; - } - - // 3b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - // StakeableLockOut - new BigIntPr(utxoInfo.stakeableLocktime), - TransferInput.fromNative( - // TransferOutput - utxoInfo.amount, - sigData.sigIndicies, - ), - ), - ), - ); - - // 3c. Consume the locked asset and get the remaining amount. - const remainingAmount = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 3d. Add the stake output. - spendHelper.addStakedOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - - // 3e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - } - } - - // 4. Add all remaining stake amounts assuming they are unlocked. - for (const [assetId, amount] of state.toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - state.spendOptions.changeAddresses, - ), - ); - } - - return state; -}; - -export const useUnlockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = - state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter( - ( - utxo, - ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; - } - - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return ( - utxo.output.getLocktime() < state.spendOptions.minIssuanceTime - ); - } - - return false; - }, - ); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => - isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. - const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = - verifiedUsableUTXOs.reduce( - (result, { sigData, data: utxo }) => { - if (utxo.assetId.value() === context.avaxAssetID) { - return [result[0], [...result[1], { sigData, data: utxo }]]; - } - - return [[...result[0], { sigData, data: utxo }], result[1]]; - }, - [[], []] as [ - other: typeof verifiedUsableUTXOs, - avax: typeof verifiedUsableUTXOs, - ], - ); - - // 4. Handle all the non-AVAX asset UTXOs first. - for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToBurn: bigint = - state.toBurn.get(utxoInfo.assetId) ?? 0n; - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. - if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { - continue; - } - - // 4b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - // 4c. Consume the asset and get the remaining amount. - const remainingAmount = spendHelper.consumeAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 4d. If "amountToStake" is greater than 0, add the stake output. - // TODO: Implement or determine if needed. - - // 4e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(remainingAmount), - OutputOwners.fromNative( - state.spendOptions.changeAddresses, - 0n, - 1, - ), - ), - ), - ), - ); - } - } - - // 5. Handle AVAX asset UTXOs last to account for fees. - let excessAVAX = state.excessAVAX; - let clearOwnerOverride = false; - for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - const remainingAmount = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); - - excessAVAX += remainingAmount; - - // The ownerOverride is no longer needed. Clear it. - clearOwnerOverride = true; - } - - return { - ...state, - excessAVAX, - ownerOverride: clearOwnerOverride ? null : state.ownerOverride, - }; -}; - -export const handleFee: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - const requiredFee = spendHelper.calculateFee(); - - if (state.excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - state.excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, - ); - } - - // No need to add a change output. - if (state.excessAVAX === requiredFee) { - return state; - } - - // Use the change owner override if it exists, otherwise use the default change owner. - // This is used for import transactions. - const changeOwners = - state.ownerOverride || - OutputOwners.fromNative(state.spendOptions.changeAddresses); - - // TODO: Clean-up if this is no longer needed. - // Additionally, no need for public .addOutputComplexity(). - // - // Pre-consolidation code. - // - // spendHelper.addOutputComplexity( - // new TransferableOutput( - // Id.fromString(context.avaxAssetID), - // new TransferOutput(new BigIntPr(0n), changeOwners), - // ), - // ); - // - // Recalculate the fee with the change output. - // const requiredFeeWithChange = spendHelper.calculateFee(); - - // Calculate the fee with a temporary output complexity if a change output is needed. - const requiredFeeWithChange: bigint = spendHelper.hasChangeOutput( - context.avaxAssetID, - changeOwners, - ) - ? requiredFee - : spendHelper.calculateFeeWithTemporaryOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); - - // Add a change output if needed. - if (state.excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(state.excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } - - return state; -}; - /** * Processes the spending of assets, including burning and staking, from a list of UTXOs. * @@ -470,8 +93,10 @@ export const handleFee: SpendReducerFunction = ( * @param {SpendReducerFunction[]} spendReducers - The list of functions that will be executed to process the spend operation. * @param {Context} context - The context in which the spend operation is executed. * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, + * @returns {SpendResult} - A tuple where the first element is either null or an error, * and the second element is either the result of the spend operation or null. + * + * @throws {Error} - Thrown error or an unexpected error if is not an instance of Error. */ export const spend = ( { @@ -487,9 +112,7 @@ export const spend = ( }: SpendProps, spendReducers: readonly SpendReducerFunction[], context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { +): SpendResult => { try { const changeOwners = ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); @@ -521,24 +144,24 @@ export const spend = ( const spendReducerFunctions: readonly SpendReducerFunction[] = [ ...spendReducers, // useSpendableLockedUTXOs, + // TODO: Should we just default include this? Used on every builder. // useUnlockedUTXOs, verifyAssetsConsumed, - handleFee, + handleFeeAndChange, // Consolidation and sorting happens in the SpendHelper. ]; // Run all the spend calculation reducer logic. - spendReducerFunctions.reduce((state, next) => { - return next(state, spendHelper, context); + spendReducerFunctions.reduce((state, reducer) => { + return reducer(state, spendHelper, context); }, initialState); - return [null, spendHelper.getInputsOutputs()]; + return spendHelper.getInputsOutputs(); } catch (error) { - return [ - error instanceof Error - ? error - : new Error('An unexpected error occurred during spend calculation'), - null, - ]; + if (error instanceof Error) { + throw error; + } + + throw new Error('An unexpected error occurred during spend calculation'); } }; diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index abfbe68f8..4bd762ea2 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -3,6 +3,10 @@ import { transferableOutput, utxo, } from '../../../fixtures/avax'; +import { id } from '../../../fixtures/common'; +import { stakeableLockOut } from '../../../fixtures/pvm'; +import { TransferableOutput } from '../../../serializable'; +import { isTransferOut } from '../../../utils'; import { createDimensions, dimensionsToGas, @@ -336,4 +340,90 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { }); }); }); + + test('no consolidated outputs when `shouldConsolidateOutputs` is `false`', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + spendHelper.addChangeOutput(transferableOutput()); + spendHelper.addChangeOutput(transferableOutput()); + + const stakedTransferableOutput = new TransferableOutput( + id(), + stakeableLockOut(), + ); + + spendHelper.addStakedOutput(stakedTransferableOutput); + spendHelper.addStakedOutput(stakedTransferableOutput); + + // Calculate fee to trigger potential consolidation. + spendHelper.calculateFee(); + + const result = spendHelper.getInputsOutputs(); + + expect(result.changeOutputs).toHaveLength(2); + expect(result.stakeOutputs).toHaveLength(2); + }); + + test('consolidating outputs when `shouldConsolidateOutputs` is `true`', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + shouldConsolidateOutputs: true, + }); + + spendHelper.addChangeOutput(transferableOutput()); + spendHelper.addChangeOutput(transferableOutput()); + + const stakedTransferableOutput = new TransferableOutput( + id(), + stakeableLockOut(), + ); + + spendHelper.addStakedOutput(stakedTransferableOutput); + spendHelper.addStakedOutput(stakedTransferableOutput); + + // Calculate fee to trigger potential consolidation. + spendHelper.calculateFee(); + + const result = spendHelper.getInputsOutputs(); + + expect(result.changeOutputs).toHaveLength(1); + expect(result.stakeOutputs).toHaveLength(1); + }); + + test('calculate fee with temporary output complexity', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + const originalFee = spendHelper.calculateFee(); + + const temporaryOutput = transferableOutput(); + + expect( + spendHelper.calculateFeeWithTemporaryOutputComplexity(temporaryOutput), + ).toBeGreaterThan(originalFee); + + expect(spendHelper.calculateFee()).toBe(originalFee); + }); + + test('hasChangeOutput returns `true` when there is an AVAX change output', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + const changeOutput = transferableOutput(); + + if (!isTransferOut(changeOutput.output)) { + throw new Error('Output is not a TransferOutput'); + } + + const assetId = changeOutput.getAssetId(); + const outputOwners = changeOutput.output.outputOwners; + + expect(spendHelper.hasChangeOutput(assetId, outputOwners)).toBe(false); + + spendHelper.addChangeOutput(changeOutput); + + expect(spendHelper.hasChangeOutput(assetId, outputOwners)).toBe(true); + + expect(spendHelper.hasChangeOutput('other-asset', outputOwners)).toBe( + false, + ); + }); }); diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 81bc3b084..d7f4bcabe 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -255,12 +255,19 @@ export class SpendHelper { return fee; } + /** + * Determines if a change output with a matching asset ID and output owners exists. + * + * @param assetId The asset ID to check + * @param outputOwners The expected output owners on the asset ID + * @returns {boolean} True if a change output with matching assetId and outputOwners exists, false otherwise + */ hasChangeOutput(assetId: string, outputOwners: OutputOwners): boolean { return this.changeOutputs.some( - (output) => - output.assetId.value() === assetId && - isTransferOut(output) && - output.outputOwners.equals(outputOwners), + (transferableOutput) => + transferableOutput.assetId.value() === assetId && + isTransferOut(transferableOutput.output) && + transferableOutput.output.outputOwners.equals(outputOwners), ); } From 0fe9515501e66ee89aae031da32627722c4158ac Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Sat, 21 Sep 2024 20:41:02 -0600 Subject: [PATCH 29/39] feat: spend reducers and tests --- src/vms/pvm/etna-builder/original-spend.ts | 439 ------------------ .../pvm/etna-builder/spend-reducers.test.ts | 138 ------ src/vms/pvm/etna-builder/spend-reducers.ts | 412 ---------------- .../pvm/etna-builder/spend-reducers/errors.ts | 3 + .../spend-reducers/fixtures/reducers.ts | 56 +++ .../spend-reducers/handleFeeAndChange.test.ts | 82 ++++ .../spend-reducers/handleFeeAndChange.ts | 101 ++++ .../pvm/etna-builder/spend-reducers/index.ts | 6 + .../pvm/etna-builder/spend-reducers/types.ts | 13 + .../spend-reducers/useSpendableLockedUTXOs.ts | 143 ++++++ .../spend-reducers/useUnlockedUTXOs.ts | 172 +++++++ .../verifyAssetsConsumed.test.ts | 33 ++ .../spend-reducers/verifyAssetsConsumed.ts | 19 + src/vms/pvm/etna-builder/spendHelper.test.ts | 188 ++++---- 14 files changed, 722 insertions(+), 1083 deletions(-) delete mode 100644 src/vms/pvm/etna-builder/original-spend.ts delete mode 100644 src/vms/pvm/etna-builder/spend-reducers.test.ts delete mode 100644 src/vms/pvm/etna-builder/spend-reducers.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/errors.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/index.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/types.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.test.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.ts diff --git a/src/vms/pvm/etna-builder/original-spend.ts b/src/vms/pvm/etna-builder/original-spend.ts deleted file mode 100644 index 8d10d0881..000000000 --- a/src/vms/pvm/etna-builder/original-spend.ts +++ /dev/null @@ -1,439 +0,0 @@ -// TODO: Delete this file once we are done referencing it. - -import type { Address } from '../../../serializable'; -import { - BigIntPr, - Id, - Input, - OutputOwners, - TransferInput, - TransferOutput, - TransferableInput, - TransferableOutput, -} from '../../../serializable'; -import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo } from '../../../utils'; -import { matchOwners } from '../../../utils/matchOwners'; -import type { SpendOptions } from '../../common'; -import type { Dimensions } from '../../common/fees/dimensions'; -import type { Context } from '../../context'; -import { SpendHelper } from './spendHelper'; - -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `locked` contains UTXOs that have a locktime greater than or equal to `minIssuanceTime`. - * - `unlocked` contains UTXOs that have a locktime less than `minIssuanceTime`. - * - * @param utxos {readonly Utxo[]} - * @param minIssuanceTime {bigint} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByLocktime = ( - utxos: readonly Utxo[], - minIssuanceTime: bigint, -): { readonly locked: readonly Utxo[]; readonly unlocked: readonly Utxo[] } => { - const locked: Utxo[] = []; - const unlocked: Utxo[] = []; - - for (const utxo of utxos) { - let utxoOwnersLocktime: bigint; - - // TODO: Remove this try catch in the future in favor of - // filtering out unusable utxos similar to useUnlockedUtxos/useSpendableLockedUTXOs - try { - utxoOwnersLocktime = getUtxoInfo(utxo).stakeableLocktime; - } catch (error) { - // If we can't get the locktime, we can't spend the UTXO. - // TODO: Is this the right thing to do? - // This was necessary to get tests working with testUtxos(). - continue; - } - - if (minIssuanceTime < utxoOwnersLocktime) { - locked.push(utxo); - } else { - unlocked.push(utxo); - } - } - - return { locked, unlocked }; -}; - -/** - * @internal - * - * Separates the provided UTXOs into two lists: - * - `other` contains UTXOs that have an asset ID different from `assetId`. - * - `requested` contains UTXOs that have an asset ID equal to `assetId`. - * - * @param utxos {readonly Utxo[]} - * @param assetId {string} - * - * @returns Object containing two lists of UTXOs. - */ -export const splitByAssetId = ( - utxos: readonly Utxo[], - assetId: string, -): { readonly other: readonly Utxo[]; readonly requested: readonly Utxo[] } => { - const other: Utxo[] = []; - const requested: Utxo[] = []; - - for (const utxo of utxos) { - if (assetId === utxo.assetId.toString()) { - requested.push(utxo); - } else { - other.push(utxo); - } - } - - return { other, requested }; -}; - -type SpendResult = Readonly<{ - changeOutputs: readonly TransferableOutput[]; - inputs: readonly TransferableInput[]; - inputUTXOs: readonly Utxo[]; - stakeOutputs: readonly TransferableOutput[]; -}>; - -export type SpendProps = Readonly<{ - /** - * The initial complexity of the transaction. - */ - complexity: Dimensions; - /** - * The extra AVAX that spend can produce in - * the change outputs in addition to the consumed and not burned AVAX. - */ - excessAVAX?: bigint; - /** - * List of Addresses that are used for selecting which UTXOs are signable. - */ - fromAddresses: readonly Address[]; - /** - * Optionally specifies the output owners to use for the unlocked - * AVAX change output if no additional AVAX was needed to be burned. - * If this value is `undefined`, the default change owner is used. - */ - ownerOverride?: OutputOwners; - spendOptions: Required; - /** - * Maps `assetID` to the amount of the asset to spend without - * producing an output. This is typically used for fees. - * However, it can also be used to consume some of an asset that - * will be produced in separate outputs, such as ExportedOutputs. - * - * Only unlocked UTXOs are able to be burned here. - */ - toBurn?: Map; - /** - * Maps `assetID` to the amount of the asset to spend and place info - * the staked outputs. First locked UTXOs are attempted to be used for - * these funds, and then unlocked UTXOs will be attempted to be used. - * There is no preferential ordering on the unlock times. - */ - toStake?: Map; - /** - * List of UTXOs that are available to be spent. - */ - utxos: readonly Utxo[]; -}>; - -/** - * Processes the spending of assets, including burning and staking, from a list of UTXOs. - * - * @param {SpendProps} props - The properties required to execute the spend operation. - * @param {Context} context - The context in which the spend operation is executed. - * - * @returns {[null, SpendResult] | [Error, null]} - A tuple where the first element is either null or an error, - * and the second element is either the result of the spend operation or null. - */ -export const spend = ( - { - complexity, - excessAVAX: _excessAVAX = 0n, - fromAddresses, - ownerOverride, - spendOptions, - toBurn = new Map(), - toStake = new Map(), - utxos, - }: SpendProps, - context: Context, -): - | [error: null, inputsAndOutputs: SpendResult] - | [error: Error, inputsAndOutputs: null] => { - try { - let changeOwners = - ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - let excessAVAX: bigint = _excessAVAX; - - const spendHelper = new SpendHelper({ - changeOutputs: [], - initialComplexity: complexity, - gasPrice: context.gasPrice, - inputs: [], - shouldConsolidateOutputs: false, - stakeOutputs: [], - toBurn, - toStake, - weights: context.complexityWeights, - }); - - const utxosByLocktime = splitByLocktime( - utxos, - spendOptions.minIssuanceTime, - ); - - for (const utxo of utxosByLocktime.locked) { - if (!spendHelper.shouldConsumeLockedAsset(utxo.assetId.toString())) { - continue; - } - - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; - - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ), - ); - - const excess = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - spendHelper.addStakedOutput( - // TODO: Verify this. - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - excess), - utxo.getOutputOwners(), - ), - ), - ), - ); - - if (excess === 0n) { - continue; - } - - // This input had extra value, so some of it must be returned as change. - spendHelper.addChangeOutput( - // TODO: Verify this. - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput(new BigIntPr(excess), utxo.getOutputOwners()), - ), - ), - ); - } - - // Add all remaining stake amounts assuming unlocked UTXOs - for (const [assetId, amount] of toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - spendOptions.changeAddresses, - ), - ); - } - - // AVAX is handled last to account for fees. - const utxosByAVAXAssetId = splitByAssetId( - utxosByLocktime.unlocked, - context.avaxAssetID, - ); - - for (const utxo of utxosByAVAXAssetId.other) { - const assetId = utxo.assetId.toString(); - - if (!spendHelper.shouldConsumeAsset(assetId)) { - continue; - } - - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; - - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - // TODO: Verify this. - // TransferableInput.fromUtxoAndSigindicies(utxo, inputSigIndices), - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); - - const excess = spendHelper.consumeAsset(assetId, utxoInfo.amount); - - if (excess === 0n) { - continue; - } - - // This input had extra value, so some of it must be returned as change. - spendHelper.addChangeOutput( - // TODO: Verify this. - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(excess), - OutputOwners.fromNative(spendOptions.changeAddresses), - ), - ), - ); - } - - for (const utxo of utxosByAVAXAssetId.requested) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } - - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - [...fromAddresses], - spendOptions.minIssuanceTime, - ) || {}; - - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - // TODO: Verify this. - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - new BigIntPr(utxoInfo.amount), - Input.fromNative(inputSigIndices), - ), - ), - ); - - const excess = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); - - excessAVAX += excess; - - // If we need to consume additional AVAX, we should be returning the - // change to the change address. - changeOwners = OutputOwners.fromNative(spendOptions.changeAddresses); - } - - // Verify - const verifyError = spendHelper.verifyAssetsConsumed(); - if (verifyError) { - return [verifyError, null]; - } - - const requiredFee = spendHelper.calculateFee(); - - if (excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, - ); - } - - // NOTE: This logic differs a bit from avalanche go because our classes are immutable. - spendHelper.addOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); - - const requiredFeeWithChange = spendHelper.calculateFee(); - - if (excessAVAX > requiredFeeWithChange) { - // It is worth adding the change output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(excessAVAX - requiredFeeWithChange), - changeOwners, - ), - ), - ); - } - - // Sorting happens in the .getInputsOutputs() method. - return [null, spendHelper.getInputsOutputs()]; - } catch (error) { - return [ - new Error('An unexpected error occurred during spend calculation', { - cause: error instanceof Error ? error : undefined, - }), - null, - ]; - } -}; diff --git a/src/vms/pvm/etna-builder/spend-reducers.test.ts b/src/vms/pvm/etna-builder/spend-reducers.test.ts deleted file mode 100644 index 8cdd6b3b0..000000000 --- a/src/vms/pvm/etna-builder/spend-reducers.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { jest } from '@jest/globals'; - -import { testContext } from '../../../fixtures/context'; -import { Address, OutputOwners } from '../../../serializable'; -import { defaultSpendOptions } from '../../common/defaultSpendOptions'; -import { createDimensions } from '../../common/fees/dimensions'; -import type { SpendHelperProps } from './spendHelper'; -import { SpendHelper } from './spendHelper'; -import type { SpendReducerState } from './spend-reducers'; -import { handleFeeAndChange } from './spend-reducers'; - -const CHANGE_ADDRESS = Address.fromString( - 'P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg', -); -const CHANGE_OWNERS: OutputOwners = OutputOwners.fromNative([ - CHANGE_ADDRESS.toBytes(), -]); - -const getInitialReducerState = ( - state: Partial = {}, -): SpendReducerState => ({ - excessAVAX: 0n, - initialComplexity: createDimensions(1, 1, 1, 1), - fromAddresses: [CHANGE_ADDRESS], - ownerOverride: null, - spendOptions: defaultSpendOptions( - state?.fromAddresses?.map((address) => address.toBytes()) ?? [ - CHANGE_ADDRESS.toBytes(), - ], - ), - toBurn: new Map(), - toStake: new Map(), - utxos: [], - ...state, -}); - -const getSpendHelper = ({ - initialComplexity = createDimensions(1, 1, 1, 1), - shouldConsolidateOutputs = false, - toBurn = new Map(), - toStake = new Map(), -}: Partial< - Pick< - SpendHelperProps, - 'initialComplexity' | 'shouldConsolidateOutputs' | 'toBurn' | 'toStake' - > -> = {}) => { - return new SpendHelper({ - changeOutputs: [], - gasPrice: testContext.gasPrice, - initialComplexity, - inputs: [], - shouldConsolidateOutputs, - stakeOutputs: [], - toBurn, - toStake, - weights: testContext.complexityWeights, - }); -}; - -describe('./src/vms/pvm/etna-builder/spend-reducers.test.ts', () => { - describe('handleFeeAndChange', () => { - test('throws an error if excessAVAX is less than the required fee', () => { - expect(() => - handleFeeAndChange( - getInitialReducerState(), - getSpendHelper(), - testContext, - ), - ).toThrow( - `Insufficient funds: provided UTXOs need 4 more nAVAX (asset id: ${testContext.avaxAssetID})`, - ); - }); - - test('returns original state if excessAVAX equals the required fee', () => { - const state = getInitialReducerState({ excessAVAX: 4n }); - const spendHelper = getSpendHelper(); - const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); - const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( - spendHelper, - 'calculateFeeWithTemporaryOutputComplexity', - ); - - expect(handleFeeAndChange(state, getSpendHelper(), testContext)).toEqual( - state, - ); - expect( - calculateFeeWithTemporaryOutputComplexitySpy, - ).not.toHaveBeenCalled(); - expect(addChangeOutputSpy).not.toHaveBeenCalled(); - }); - - test('adds a change output if excessAVAX is greater than the required fee', () => { - const excessAVAX = 1_000n; - const state = getInitialReducerState({ - excessAVAX, - }); - const spendHelper = getSpendHelper(); - - const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); - const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( - spendHelper, - 'calculateFeeWithTemporaryOutputComplexity', - ); - - expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual({ - ...state, - excessAVAX, - }); - expect( - calculateFeeWithTemporaryOutputComplexitySpy, - ).toHaveBeenCalledTimes(1); - expect(addChangeOutputSpy).toHaveBeenCalledTimes(1); - - expect( - spendHelper.hasChangeOutput(testContext.avaxAssetID, CHANGE_OWNERS), - ).toBe(true); - - expect(spendHelper.getInputsOutputs().changeOutputs).toHaveLength(1); - }); - - test('does not add change output if fee with temporary output complexity and excessAVAX are equal or less', () => { - const excessAVAX = 5n; - const state = getInitialReducerState({ - excessAVAX, - }); - const spendHelper = getSpendHelper(); - - const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); - - expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual( - state, - ); - - expect(addChangeOutputSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/vms/pvm/etna-builder/spend-reducers.ts b/src/vms/pvm/etna-builder/spend-reducers.ts deleted file mode 100644 index 9d57a80e0..000000000 --- a/src/vms/pvm/etna-builder/spend-reducers.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { - BigIntPr, - Id, - OutputOwners, - TransferInput, - TransferOutput, - TransferableInput, - TransferableOutput, -} from '../../../serializable'; -import type { Utxo } from '../../../serializable/avax/utxo'; -import { StakeableLockIn, StakeableLockOut } from '../../../serializable/pvm'; -import { getUtxoInfo, isStakeableLockOut, isTransferOut } from '../../../utils'; -import type { Context } from '../../context'; -import { verifySignaturesMatch } from '../../utils/calculateSpend/utils'; -import type { SpendProps } from './spend'; -import type { SpendHelper } from './spendHelper'; - -export type SpendReducerState = Readonly< - Required> ->; - -export type SpendReducerFunction = ( - state: SpendReducerState, - spendHelper: SpendHelper, - context: Context, -) => SpendReducerState; - -export const verifyAssetsConsumed: SpendReducerFunction = ( - state, - spendHelper, -) => { - const verifyError = spendHelper.verifyAssetsConsumed(); - - if (verifyError) { - throw verifyError; - } - - return state; -}; - -export const IncorrectStakeableLockOutError = new Error( - 'StakeableLockOut transferOut must be a TransferOutput.', -); - -export const useSpendableLockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter((utxo): utxo is Utxo> => { - // 1a. Ensure UTXO output is a StakeableLockOut. - if (!isStakeableLockOut(utxo.output)) { - return false; - } - - // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { - return false; - } - - // 1c. Ensure there are funds to stake. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { - return false; - } - - // 1d. Ensure transferOut is a TransferOutput. - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return true; - }); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Do all the logic for spending based on the UTXOs. - for (const { sigData, data: utxo } of verifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. - if (remainingAmountToStake === 0n) { - continue; - } - - // 3b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new StakeableLockIn( - // StakeableLockOut - new BigIntPr(utxoInfo.stakeableLocktime), - TransferInput.fromNative( - // TransferOutput - utxoInfo.amount, - sigData.sigIndicies, - ), - ), - ), - ); - - // 3c. Consume the locked asset and get the remaining amount. - const remainingAmount = spendHelper.consumeLockedAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 3d. Add the stake output. - spendHelper.addStakedOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(utxoInfo.amount - remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - - // 3e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new StakeableLockOut( - new BigIntPr(utxoInfo.stakeableLocktime), - new TransferOutput( - new BigIntPr(remainingAmount), - utxo.getOutputOwners(), - ), - ), - ), - ); - } - } - - // 4. Add all remaining stake amounts assuming they are unlocked. - for (const [assetId, amount] of state.toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - state.spendOptions.changeAddresses, - ), - ); - } - - return state; -}; - -export const useUnlockedUTXOs: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - // 1. Filter out the UTXOs that are not usable. - const usableUTXOs: Utxo>[] = - state.utxos - // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter( - ( - utxo, - ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; - } - - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return ( - utxo.output.getLocktime() < state.spendOptions.minIssuanceTime - ); - } - - return false; - }, - ); - - // 2. Verify signatures match. - const verifiedUsableUTXOs = verifySignaturesMatch( - usableUTXOs, - (utxo) => - isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, - state.fromAddresses, - state.spendOptions, - ); - - // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. - const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = - verifiedUsableUTXOs.reduce( - (result, { sigData, data: utxo }) => { - if (utxo.assetId.value() === context.avaxAssetID) { - return [result[0], [...result[1], { sigData, data: utxo }]]; - } - - return [[...result[0], { sigData, data: utxo }], result[1]]; - }, - [[], []] as [ - other: typeof verifiedUsableUTXOs, - avax: typeof verifiedUsableUTXOs, - ], - ); - - // 4. Handle all the non-AVAX asset UTXOs first. - for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { - const utxoInfo = getUtxoInfo(utxo); - const remainingAmountToBurn: bigint = - state.toBurn.get(utxoInfo.assetId) ?? 0n; - const remainingAmountToStake: bigint = - state.toStake.get(utxoInfo.assetId) ?? 0n; - - // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. - if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { - continue; - } - - // 4b. Add the input. - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - // 4c. Consume the asset and get the remaining amount. - const remainingAmount = spendHelper.consumeAsset( - utxoInfo.assetId, - utxoInfo.amount, - ); - - // 4d. If "amountToStake" is greater than 0, add the stake output. - // TODO: Implement or determine if needed. - - // 4e. Add the change output if there is any remaining amount. - if (remainingAmount > 0n) { - spendHelper.addChangeOutput( - new TransferableOutput( - utxo.assetId, - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(remainingAmount), - OutputOwners.fromNative( - state.spendOptions.changeAddresses, - 0n, - 1, - ), - ), - ), - ), - ); - } - } - - // 5. Handle AVAX asset UTXOs last to account for fees. - let excessAVAX = state.excessAVAX; - let clearOwnerOverride = false; - for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { - const requiredFee = spendHelper.calculateFee(); - - // If we don't need to burn or stake additional AVAX and we have - // consumed enough AVAX to pay the required fee, we should stop - // consuming UTXOs. - if ( - !spendHelper.shouldConsumeAsset(context.avaxAssetID) && - excessAVAX >= requiredFee - ) { - break; - } - - const utxoInfo = getUtxoInfo(utxo); - - spendHelper.addInput( - utxo, - new TransferableInput( - utxo.utxoId, - utxo.assetId, - TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), - ), - ); - - const remainingAmount = spendHelper.consumeAsset( - context.avaxAssetID, - utxoInfo.amount, - ); - - excessAVAX += remainingAmount; - - // The ownerOverride is no longer needed. Clear it. - clearOwnerOverride = true; - } - - return { - ...state, - excessAVAX, - ownerOverride: clearOwnerOverride ? null : state.ownerOverride, - }; -}; - -/** - * Determines if the fee can be covered by the excess AVAX. - * - * @returns {boolean} - Whether the excess AVAX exceeds the fee. `true` greater than the fee, `false` if equal. - * @throws {Error} - If the excess AVAX is less than the required fee. - */ -const canPayFeeAndNeedsChange = ( - excessAVAX: bigint, - requiredFee: bigint, - context: Context, -): boolean => { - // Not enough funds to pay the fee. - if (excessAVAX < requiredFee) { - throw new Error( - `Insufficient funds: provided UTXOs need ${ - requiredFee - excessAVAX - } more nAVAX (asset id: ${context.avaxAssetID})`, - ); - } - - // No need to add a change to change output. - // Just burn the fee. - if (excessAVAX === requiredFee) { - return false; - } - - return true; -}; - -export const handleFeeAndChange: SpendReducerFunction = ( - state, - spendHelper, - context, -) => { - // Use the change owner override if it exists, otherwise use the default change owner. - // This is used on "import" transactions. - const changeOwners = - state.ownerOverride ?? - OutputOwners.fromNative(state.spendOptions.changeAddresses); - - const requiredFee = spendHelper.calculateFee(); - - // Checks for an existing change output that is for the AVAX asset assigned to the change owner. - const hasExistingChangeOutput: boolean = spendHelper.hasChangeOutput( - context.avaxAssetID, - changeOwners, - ); - - if (canPayFeeAndNeedsChange(state.excessAVAX, requiredFee, context)) { - if (hasExistingChangeOutput) { - // Excess exceeds fee, return the change. - // This output will get consolidated with the existing output. - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(state.excessAVAX - requiredFee), - changeOwners, - ), - ), - ); - } else { - // Calculate the fee with a temporary output complexity - // as if we added the change output. - const requiredFeeWithChangeOutput = - spendHelper.calculateFeeWithTemporaryOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); - - // If the excess AVAX is greater than the new fee, add a change output. - // Otherwise, ignore and burn the excess because it can't be returned - // (ie we can't pay the fee to return the excess). - if (state.excessAVAX > requiredFeeWithChangeOutput) { - spendHelper.addChangeOutput( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput( - new BigIntPr(state.excessAVAX - requiredFeeWithChangeOutput), - changeOwners, - ), - ), - ); - } - } - } - - return state; -}; diff --git a/src/vms/pvm/etna-builder/spend-reducers/errors.ts b/src/vms/pvm/etna-builder/spend-reducers/errors.ts new file mode 100644 index 000000000..cbc6c4c39 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/errors.ts @@ -0,0 +1,3 @@ +export const IncorrectStakeableLockOutError = new Error( + 'StakeableLockOut transferOut must be a TransferOutput.', +); diff --git a/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts b/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts new file mode 100644 index 000000000..0a4b75f06 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts @@ -0,0 +1,56 @@ +import { testContext } from '../../../../../fixtures/context'; +import { Address, OutputOwners } from '../../../../../serializable'; +import { defaultSpendOptions } from '../../../../common/defaultSpendOptions'; +import { createDimensions } from '../../../../common/fees/dimensions'; +import type { SpendHelperProps } from '../../spendHelper'; +import { SpendHelper } from '../../spendHelper'; +import type { SpendReducerState } from '../types'; + +export const CHANGE_ADDRESS = Address.fromString( + 'P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg', +); +export const CHANGE_OWNERS: OutputOwners = OutputOwners.fromNative([ + CHANGE_ADDRESS.toBytes(), +]); + +export const getInitialReducerState = ( + state: Partial = {}, +): SpendReducerState => ({ + excessAVAX: 0n, + initialComplexity: createDimensions(1, 1, 1, 1), + fromAddresses: [CHANGE_ADDRESS], + ownerOverride: null, + spendOptions: defaultSpendOptions( + state?.fromAddresses?.map((address) => address.toBytes()) ?? [ + CHANGE_ADDRESS.toBytes(), + ], + ), + toBurn: new Map(), + toStake: new Map(), + utxos: [], + ...state, +}); + +export const getSpendHelper = ({ + initialComplexity = createDimensions(1, 1, 1, 1), + shouldConsolidateOutputs = false, + toBurn = new Map(), + toStake = new Map(), +}: Partial< + Pick< + SpendHelperProps, + 'initialComplexity' | 'shouldConsolidateOutputs' | 'toBurn' | 'toStake' + > +> = {}) => { + return new SpendHelper({ + changeOutputs: [], + gasPrice: testContext.gasPrice, + initialComplexity, + inputs: [], + shouldConsolidateOutputs, + stakeOutputs: [], + toBurn, + toStake, + weights: testContext.complexityWeights, + }); +}; diff --git a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts new file mode 100644 index 000000000..059873e22 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts @@ -0,0 +1,82 @@ +import { jest } from '@jest/globals'; + +import { testContext } from '../../../../fixtures/context'; +import { handleFeeAndChange } from './handleFeeAndChange'; +import { + CHANGE_OWNERS, + getInitialReducerState, + getSpendHelper, +} from './fixtures/reducers'; + +describe('handleFeeAndChange', () => { + test('throws an error if excessAVAX is less than the required fee', () => { + expect(() => + handleFeeAndChange( + getInitialReducerState(), + getSpendHelper(), + testContext, + ), + ).toThrow( + `Insufficient funds: provided UTXOs need 4 more nAVAX (asset id: ${testContext.avaxAssetID})`, + ); + }); + + test('returns original state if excessAVAX equals the required fee', () => { + const state = getInitialReducerState({ excessAVAX: 4n }); + const spendHelper = getSpendHelper(); + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( + spendHelper, + 'calculateFeeWithTemporaryOutputComplexity', + ); + + expect(handleFeeAndChange(state, getSpendHelper(), testContext)).toEqual( + state, + ); + expect(calculateFeeWithTemporaryOutputComplexitySpy).not.toHaveBeenCalled(); + expect(addChangeOutputSpy).not.toHaveBeenCalled(); + }); + + test('adds a change output if excessAVAX is greater than the required fee', () => { + const excessAVAX = 1_000n; + const state = getInitialReducerState({ + excessAVAX, + }); + const spendHelper = getSpendHelper(); + + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( + spendHelper, + 'calculateFeeWithTemporaryOutputComplexity', + ); + + expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual({ + ...state, + excessAVAX, + }); + expect(calculateFeeWithTemporaryOutputComplexitySpy).toHaveBeenCalledTimes( + 1, + ); + expect(addChangeOutputSpy).toHaveBeenCalledTimes(1); + + expect( + spendHelper.hasChangeOutput(testContext.avaxAssetID, CHANGE_OWNERS), + ).toBe(true); + + expect(spendHelper.getInputsOutputs().changeOutputs).toHaveLength(1); + }); + + test('does not add change output if fee with temporary output complexity and excessAVAX are equal or less', () => { + const excessAVAX = 5n; + const state = getInitialReducerState({ + excessAVAX, + }); + const spendHelper = getSpendHelper(); + + const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); + + expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual(state); + + expect(addChangeOutputSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts new file mode 100644 index 000000000..644b31b17 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts @@ -0,0 +1,101 @@ +import { + BigIntPr, + Id, + OutputOwners, + TransferOutput, + TransferableOutput, +} from '../../../../serializable'; +import type { Context } from '../../../context'; +import type { SpendReducerFunction } from './types'; + +/** + * Determines if the fee can be covered by the excess AVAX. + * + * @returns {boolean} - Whether the excess AVAX exceeds the fee. `true` greater than the fee, `false` if equal. + * @throws {Error} - If the excess AVAX is less than the required fee. + */ +const canPayFeeAndNeedsChange = ( + excessAVAX: bigint, + requiredFee: bigint, + context: Context, +): boolean => { + // Not enough funds to pay the fee. + if (excessAVAX < requiredFee) { + throw new Error( + `Insufficient funds: provided UTXOs need ${ + requiredFee - excessAVAX + } more nAVAX (asset id: ${context.avaxAssetID})`, + ); + } + + // No need to add a change to change output. + // Just burn the fee. + if (excessAVAX === requiredFee) { + return false; + } + + return true; +}; + +export const handleFeeAndChange: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // Use the change owner override if it exists, otherwise use the default change owner. + // This is used on "import" transactions. + const changeOwners = + state.ownerOverride ?? + OutputOwners.fromNative(state.spendOptions.changeAddresses); + + const requiredFee = spendHelper.calculateFee(); + + // Checks for an existing change output that is for the AVAX asset assigned to the change owner. + const hasExistingChangeOutput: boolean = spendHelper.hasChangeOutput( + context.avaxAssetID, + changeOwners, + ); + + if (canPayFeeAndNeedsChange(state.excessAVAX, requiredFee, context)) { + if (hasExistingChangeOutput) { + // Excess exceeds fee, return the change. + // This output will get consolidated with the existing output. + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFee), + changeOwners, + ), + ), + ); + } else { + // Calculate the fee with a temporary output complexity + // as if we added the change output. + const requiredFeeWithChangeOutput = + spendHelper.calculateFeeWithTemporaryOutputComplexity( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); + + // If the excess AVAX is greater than the new fee, add a change output. + // Otherwise, ignore and burn the excess because it can't be returned + // (ie we can't pay the fee to return the excess). + if (state.excessAVAX > requiredFeeWithChangeOutput) { + spendHelper.addChangeOutput( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput( + new BigIntPr(state.excessAVAX - requiredFeeWithChangeOutput), + changeOwners, + ), + ), + ); + } + } + } + + return state; +}; diff --git a/src/vms/pvm/etna-builder/spend-reducers/index.ts b/src/vms/pvm/etna-builder/spend-reducers/index.ts new file mode 100644 index 000000000..e81dd077c --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/index.ts @@ -0,0 +1,6 @@ +export { handleFeeAndChange } from './handleFeeAndChange'; +export { useSpendableLockedUTXOs } from './useSpendableLockedUTXOs'; +export { useUnlockedUTXOs } from './useUnlockedUTXOs'; +export { verifyAssetsConsumed } from './verifyAssetsConsumed'; + +export type * from './types'; diff --git a/src/vms/pvm/etna-builder/spend-reducers/types.ts b/src/vms/pvm/etna-builder/spend-reducers/types.ts new file mode 100644 index 000000000..efffe2c94 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/types.ts @@ -0,0 +1,13 @@ +import type { Context } from '../../../context'; +import type { SpendProps } from '../spend'; +import type { SpendHelper } from '../spendHelper'; + +export type SpendReducerState = Readonly< + Required> +>; + +export type SpendReducerFunction = ( + state: SpendReducerState, + spendHelper: SpendHelper, + context: Context, +) => SpendReducerState; diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts new file mode 100644 index 000000000..5b59f08cb --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts @@ -0,0 +1,143 @@ +import { + BigIntPr, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../../serializable'; +import type { Utxo } from '../../../../serializable/avax/utxo'; +import { + StakeableLockIn, + StakeableLockOut, +} from '../../../../serializable/pvm'; +import { + getUtxoInfo, + isStakeableLockOut, + isTransferOut, +} from '../../../../utils'; +import { verifySignaturesMatch } from '../../../utils/calculateSpend/utils'; +import { IncorrectStakeableLockOutError } from './errors'; +import type { SpendReducerFunction } from './types'; + +export const useSpendableLockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter((utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; + } + + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } + + // 1c. Ensure there are funds to stake. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; + } + + // 1d. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return true; + }); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Do all the logic for spending based on the UTXOs. + for (const { sigData, data: utxo } of verifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 3a. If we have already reached the stake amount, there is nothing left to run beyond here. + if (remainingAmountToStake === 0n) { + continue; + } + + // 3b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new StakeableLockIn( + // StakeableLockOut + new BigIntPr(utxoInfo.stakeableLocktime), + TransferInput.fromNative( + // TransferOutput + utxoInfo.amount, + sigData.sigIndicies, + ), + ), + ), + ); + + // 3c. Consume the locked asset and get the remaining amount. + const remainingAmount = spendHelper.consumeLockedAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 3d. Add the stake output. + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(utxoInfo.amount - remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + + // 3e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new StakeableLockOut( + new BigIntPr(utxoInfo.stakeableLocktime), + new TransferOutput( + new BigIntPr(remainingAmount), + utxo.getOutputOwners(), + ), + ), + ), + ); + } + } + + // 4. Add all remaining stake amounts assuming they are unlocked. + for (const [assetId, amount] of state.toStake) { + if (amount === 0n) { + continue; + } + + spendHelper.addStakedOutput( + TransferableOutput.fromNative( + assetId, + amount, + state.spendOptions.changeAddresses, + ), + ); + } + + return state; +}; diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts new file mode 100644 index 000000000..796c50d54 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts @@ -0,0 +1,172 @@ +import { + BigIntPr, + OutputOwners, + TransferInput, + TransferOutput, + TransferableInput, + TransferableOutput, +} from '../../../../serializable'; +import type { Utxo } from '../../../../serializable/avax/utxo'; +import type { StakeableLockOut } from '../../../../serializable/pvm'; +import { + getUtxoInfo, + isStakeableLockOut, + isTransferOut, +} from '../../../../utils'; +import { verifySignaturesMatch } from '../../../utils/calculateSpend/utils'; +import { IncorrectStakeableLockOutError } from './errors'; +import type { SpendReducerFunction } from './types'; + +export const useUnlockedUTXOs: SpendReducerFunction = ( + state, + spendHelper, + context, +) => { + // 1. Filter out the UTXOs that are not usable. + const usableUTXOs: Utxo>[] = + state.utxos + // Filter out non stakeable lockouts and lockouts that are not stakeable yet. + .filter( + ( + utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return ( + utxo.output.getLocktime() < state.spendOptions.minIssuanceTime + ); + } + + return false; + }, + ); + + // 2. Verify signatures match. + const verifiedUsableUTXOs = verifySignaturesMatch( + usableUTXOs, + (utxo) => + isTransferOut(utxo.output) ? utxo.output : utxo.output.transferOut, + state.fromAddresses, + state.spendOptions, + ); + + // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. + const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = + verifiedUsableUTXOs.reduce( + (result, { sigData, data: utxo }) => { + if (utxo.assetId.value() === context.avaxAssetID) { + return [result[0], [...result[1], { sigData, data: utxo }]]; + } + + return [[...result[0], { sigData, data: utxo }], result[1]]; + }, + [[], []] as [ + other: typeof verifiedUsableUTXOs, + avax: typeof verifiedUsableUTXOs, + ], + ); + + // 4. Handle all the non-AVAX asset UTXOs first. + for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { + const utxoInfo = getUtxoInfo(utxo); + const remainingAmountToBurn: bigint = + state.toBurn.get(utxoInfo.assetId) ?? 0n; + const remainingAmountToStake: bigint = + state.toStake.get(utxoInfo.assetId) ?? 0n; + + // 4a. If we have already reached the burn/stake amount, there is nothing left to run beyond here. + if (remainingAmountToBurn === 0n && remainingAmountToStake === 0n) { + continue; + } + + // 4b. Add the input. + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + // 4c. Consume the asset and get the remaining amount. + const remainingAmount = spendHelper.consumeAsset( + utxoInfo.assetId, + utxoInfo.amount, + ); + + // 4d. If "amountToStake" is greater than 0, add the stake output. + // TODO: Implement or determine if needed. + + // 4e. Add the change output if there is any remaining amount. + if (remainingAmount > 0n) { + spendHelper.addChangeOutput( + new TransferableOutput( + utxo.assetId, + new TransferableOutput( + utxo.assetId, + new TransferOutput( + new BigIntPr(remainingAmount), + OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ), + ), + ), + ), + ); + } + } + + // 5. Handle AVAX asset UTXOs last to account for fees. + let excessAVAX = state.excessAVAX; + let clearOwnerOverride = false; + for (const { sigData, data: utxo } of avaxVerifiedUsableUTXOs) { + const requiredFee = spendHelper.calculateFee(); + + // If we don't need to burn or stake additional AVAX and we have + // consumed enough AVAX to pay the required fee, we should stop + // consuming UTXOs. + if ( + !spendHelper.shouldConsumeAsset(context.avaxAssetID) && + excessAVAX >= requiredFee + ) { + break; + } + + const utxoInfo = getUtxoInfo(utxo); + + spendHelper.addInput( + utxo, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), + ), + ); + + const remainingAmount = spendHelper.consumeAsset( + context.avaxAssetID, + utxoInfo.amount, + ); + + excessAVAX += remainingAmount; + + // The ownerOverride is no longer needed. Clear it. + clearOwnerOverride = true; + } + + return { + ...state, + excessAVAX, + ownerOverride: clearOwnerOverride ? null : state.ownerOverride, + }; +}; diff --git a/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.test.ts b/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.test.ts new file mode 100644 index 000000000..3367eb560 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.test.ts @@ -0,0 +1,33 @@ +import { jest } from '@jest/globals'; + +import { testContext } from '../../../../fixtures/context'; +import { getInitialReducerState, getSpendHelper } from './fixtures/reducers'; +import { verifyAssetsConsumed } from './verifyAssetsConsumed'; + +describe('verifyAssetsConsumed', () => { + test('returns original state if all assets are consumed', () => { + const initialState = getInitialReducerState(); + const spendHelper = getSpendHelper(); + const spy = jest.spyOn(spendHelper, 'verifyAssetsConsumed'); + + const state = verifyAssetsConsumed(initialState, spendHelper, testContext); + + expect(state).toBe(initialState); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('throws an error if some assets are not consumed', () => { + const initialState = getInitialReducerState(); + const spendHelper = getSpendHelper(); + + // Mock the verifyAssetsConsumed method to throw an error + // Testing for this function can be found in the spendHelper.test.ts file + spendHelper.verifyAssetsConsumed = jest.fn(() => { + throw new Error('Test error'); + }); + + expect(() => + verifyAssetsConsumed(initialState, spendHelper, testContext), + ).toThrow('Test error'); + }); +}); diff --git a/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.ts b/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.ts new file mode 100644 index 000000000..546e010ea --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/verifyAssetsConsumed.ts @@ -0,0 +1,19 @@ +import type { SpendReducerFunction } from './types'; + +/** + * Verify that all assets have been consumed. + * + * Calls the spendHelper's verifyAssetsConsumed method. + */ +export const verifyAssetsConsumed: SpendReducerFunction = ( + state, + spendHelper, +) => { + const verifyError = spendHelper.verifyAssetsConsumed(); + + if (verifyError) { + throw verifyError; + } + + return state; +}; diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 4bd762ea2..211bd517c 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -231,113 +231,113 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { }).toThrow('Amount to consume must be greater than or equal to 0'); }); }); + }); - describe('SpendHelper.consumeAsset', () => { - const testCases = [ - { - description: 'consumes the full amount', - toBurn: new Map([['asset', 1n]]), - asset: 'asset', - amount: 1n, - expected: 0n, - }, - { - description: 'consumes a partial amount', - toBurn: new Map([['asset', 1n]]), - asset: 'asset', - amount: 2n, - expected: 1n, - }, - { - description: 'consumes nothing', - toBurn: new Map([['asset', 1n]]), - asset: 'asset', - amount: 0n, - expected: 0n, - }, - { - description: 'consumes nothing when asset not in toBurn', - toBurn: new Map(), - asset: 'asset', - amount: 1n, - expected: 1n, - }, - { - description: 'consumes nothing when asset in toBurn with 0 value', - toBurn: new Map([['asset', 0n]]), - asset: 'asset', - amount: 1n, - expected: 1n, - }, - { - description: 'consumes nothing when asset in toStake with 0 value', - toBurn: new Map([['asset', 1n]]), - toStake: new Map([['asset', 0n]]), - asset: 'asset', - amount: 1n, - expected: 0n, - }, - ]; - - test.each(testCases)( - '$description', - ({ toBurn, asset, amount, expected }) => { - const spendHelper = new SpendHelper({ - ...DEFAULT_PROPS, - toBurn, - }); + describe('SpendHelper.consumeAsset', () => { + const testCases = [ + { + description: 'consumes the full amount', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + { + description: 'consumes a partial amount', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 2n, + expected: 1n, + }, + { + description: 'consumes nothing', + toBurn: new Map([['asset', 1n]]), + asset: 'asset', + amount: 0n, + expected: 0n, + }, + { + description: 'consumes nothing when asset not in toBurn', + toBurn: new Map(), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toBurn with 0 value', + toBurn: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toStake with 0 value', + toBurn: new Map([['asset', 1n]]), + toStake: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + ]; + + test.each(testCases)( + '$description', + ({ toBurn, asset, amount, expected }) => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn, + }); - expect(spendHelper.consumeAsset(asset, amount)).toBe(expected); - }, - ); + expect(spendHelper.consumeAsset(asset, amount)).toBe(expected); + }, + ); - test('throws an error when amount is negative', () => { - const spendHelper = new SpendHelper(DEFAULT_PROPS); + test('throws an error when amount is negative', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); - expect(() => { - spendHelper.consumeAsset('asset', -1n); - }).toThrow('Amount to consume must be greater than or equal to 0'); - }); + expect(() => { + spendHelper.consumeAsset('asset', -1n); + }).toThrow('Amount to consume must be greater than or equal to 0'); }); + }); - describe('SpendHelper.verifyAssetsConsumed', () => { - test('returns null when all assets consumed', () => { - const spendHelper = new SpendHelper({ - ...DEFAULT_PROPS, - toBurn: new Map([['asset', 0n]]), - toStake: new Map([['asset', 0n]]), - }); - - expect(spendHelper.verifyAssetsConsumed()).toBe(null); + describe('SpendHelper.verifyAssetsConsumed', () => { + test('returns null when all assets consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['asset', 0n]]), + toStake: new Map([['asset', 0n]]), }); - test('returns an error when stake assets not consumed', () => { - const spendHelper = new SpendHelper({ - ...DEFAULT_PROPS, - toBurn: new Map([['test-asset', 1n]]), - toStake: new Map([['test-asset', 1n]]), - }); + expect(spendHelper.verifyAssetsConsumed()).toBe(null); + }); - expect(spendHelper.verifyAssetsConsumed()).toEqual( - new Error( - 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset to stake', - ), - ); + test('returns an error when stake assets not consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['test-asset', 1n]]), + toStake: new Map([['test-asset', 1n]]), }); - test('returns an error when burn assets not consumed', () => { - const spendHelper = new SpendHelper({ - ...DEFAULT_PROPS, - toBurn: new Map([['test-asset', 1n]]), - toStake: new Map([['test-asset', 0n]]), - }); + expect(spendHelper.verifyAssetsConsumed()).toEqual( + new Error( + 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset to stake', + ), + ); + }); - expect(spendHelper.verifyAssetsConsumed()).toEqual( - new Error( - 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset', - ), - ); + test('returns an error when burn assets not consumed', () => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toBurn: new Map([['test-asset', 1n]]), + toStake: new Map([['test-asset', 0n]]), }); + + expect(spendHelper.verifyAssetsConsumed()).toEqual( + new Error( + 'Insufficient funds! Provided UTXOs need 1 more units of asset test-asset', + ), + ); }); }); From 61946ff703c445be041953e0f3fca5b667764720 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Mon, 23 Sep 2024 08:38:56 -0600 Subject: [PATCH 30/39] feat: breakout spend reducers filters --- .../spend-reducers/useSpendableLockedUTXOs.ts | 57 +++++++++++-------- .../spend-reducers/useUnlockedUTXOs.ts | 49 +++++++++------- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts index 5b59f08cb..da42cf8a9 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts @@ -17,7 +17,38 @@ import { } from '../../../../utils'; import { verifySignaturesMatch } from '../../../utils/calculateSpend/utils'; import { IncorrectStakeableLockOutError } from './errors'; -import type { SpendReducerFunction } from './types'; +import type { SpendReducerFunction, SpendReducerState } from './types'; + +/** + * Is responsible for filtering out the usable UTXOs from the list of UTXOs. + * + * @internal - Only exported for testing. + */ +export const getUsableUTXOsFilter = + (state: SpendReducerState) => + (utxo: Utxo): utxo is Utxo> => { + // 1a. Ensure UTXO output is a StakeableLockOut. + if (!isStakeableLockOut(utxo.output)) { + return false; + } + + // 1b. Ensure UTXO is stakeable. + if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + return false; + } + + // 1c. Ensure transferOut is a TransferOutput. + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + // 1d. Filter out UTXOs that aren't needed for staking. + if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + return false; + } + + return true; + }; export const useSpendableLockedUTXOs: SpendReducerFunction = ( state, @@ -26,29 +57,7 @@ export const useSpendableLockedUTXOs: SpendReducerFunction = ( // 1. Filter out the UTXOs that are not usable. const usableUTXOs: Utxo>[] = state.utxos // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter((utxo): utxo is Utxo> => { - // 1a. Ensure UTXO output is a StakeableLockOut. - if (!isStakeableLockOut(utxo.output)) { - return false; - } - - // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { - return false; - } - - // 1c. Ensure there are funds to stake. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { - return false; - } - - // 1d. Ensure transferOut is a TransferOutput. - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return true; - }); + .filter(getUsableUTXOsFilter(state)); // 2. Verify signatures match. const verifiedUsableUTXOs = verifySignaturesMatch( diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts index 796c50d54..8b9c4be37 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts @@ -15,7 +15,32 @@ import { } from '../../../../utils'; import { verifySignaturesMatch } from '../../../utils/calculateSpend/utils'; import { IncorrectStakeableLockOutError } from './errors'; -import type { SpendReducerFunction } from './types'; +import type { SpendReducerFunction, SpendReducerState } from './types'; + +/** + * Is responsible for filtering out the usable UTXOs from the list of UTXOs. + * + * @internal - Only exported for testing. + */ +export const getUsableUTXOsFilter = + (state: SpendReducerState) => + ( + utxo: Utxo, + ): utxo is Utxo> => { + if (isTransferOut(utxo.output)) { + return true; + } + + if (isStakeableLockOut(utxo.output)) { + if (!isTransferOut(utxo.output.transferOut)) { + throw IncorrectStakeableLockOutError; + } + + return utxo.output.getLocktime() < state.spendOptions.minIssuanceTime; + } + + return false; + }; export const useUnlockedUTXOs: SpendReducerFunction = ( state, @@ -26,27 +51,7 @@ export const useUnlockedUTXOs: SpendReducerFunction = ( const usableUTXOs: Utxo>[] = state.utxos // Filter out non stakeable lockouts and lockouts that are not stakeable yet. - .filter( - ( - utxo, - ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; - } - - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return ( - utxo.output.getLocktime() < state.spendOptions.minIssuanceTime - ); - } - - return false; - }, - ); + .filter(getUsableUTXOsFilter(state)); // 2. Verify signatures match. const verifiedUsableUTXOs = verifySignaturesMatch( From 7968d58a6d1195e444f04185379369e859ac1c71 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Mon, 23 Sep 2024 11:31:04 -0600 Subject: [PATCH 31/39] test: add useUnlockedUTXOs reducer tests --- .../spend-reducers/fixtures/reducers.ts | 11 +- .../spend-reducers/useUnlockedUTXOs.test.ts | 206 ++++++++++++++++++ 2 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts diff --git a/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts b/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts index 0a4b75f06..9673dbd6d 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/fixtures/reducers.ts @@ -1,5 +1,6 @@ import { testContext } from '../../../../../fixtures/context'; import { Address, OutputOwners } from '../../../../../serializable'; +import type { SpendOptions } from '../../../../common'; import { defaultSpendOptions } from '../../../../common/defaultSpendOptions'; import { createDimensions } from '../../../../common/fees/dimensions'; import type { SpendHelperProps } from '../../spendHelper'; @@ -13,9 +14,12 @@ export const CHANGE_OWNERS: OutputOwners = OutputOwners.fromNative([ CHANGE_ADDRESS.toBytes(), ]); -export const getInitialReducerState = ( - state: Partial = {}, -): SpendReducerState => ({ +export const getInitialReducerState = ({ + spendOptions, + ...state +}: Partial> & { + spendOptions?: SpendOptions; +} = {}): SpendReducerState => ({ excessAVAX: 0n, initialComplexity: createDimensions(1, 1, 1, 1), fromAddresses: [CHANGE_ADDRESS], @@ -24,6 +28,7 @@ export const getInitialReducerState = ( state?.fromAddresses?.map((address) => address.toBytes()) ?? [ CHANGE_ADDRESS.toBytes(), ], + spendOptions, ), toBurn: new Map(), toStake: new Map(), diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts new file mode 100644 index 000000000..58fe81941 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts @@ -0,0 +1,206 @@ +import { testContext } from '../../../../fixtures/context'; +import { getUsableUTXOsFilter, useUnlockedUTXOs } from './useUnlockedUTXOs'; +import { getInitialReducerState, getSpendHelper } from './fixtures/reducers'; +import { + fromAddressBytes, + getLockedUTXO, + getNotTransferOutput, + getStakeableLockoutOutput, + getValidUtxo, + testAvaxAssetID, + testUTXOID1, + testUTXOID2, +} from '../../../../fixtures/transactions'; +import { + Address, + BigIntPr, + Id, + Int, + TransferableOutput, +} from '../../../../serializable'; +import { Utxo } from '../../../../serializable/avax/utxo'; +import { + StakeableLockIn, + StakeableLockOut, +} from '../../../../serializable/pvm'; +import { IncorrectStakeableLockOutError } from './errors'; +import { addressesFromBytes, hexToBuffer } from '../../../../utils'; +import { UTXOID } from '../../../../serializable/avax'; +import { NoSigMatchError } from '../../../utils/calculateSpend/utils'; + +describe('useUnlockedUTXOs', () => { + describe('getUsableUTXOsFilter', () => { + test('returns `true` if UTXO output is a TransferOutput', () => { + expect( + getUsableUTXOsFilter(getInitialReducerState())(getLockedUTXO()), + ).toBe(true); + }); + + test('returns `true` if UTXO output is a StakeableLockOut and the locktime is less than the minIssuanceTime', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + + const utxo = getStakeableLockoutOutput(testUTXOID1, 100n, 50n); + + expect(getUsableUTXOsFilter(state)(utxo)).toBe(true); + }); + + test('returns `false` if UTXO output is a StakeableLockOut and the locktime is equal or greater than the minIssuanceTime', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + + const utxo = getStakeableLockoutOutput(testUTXOID1, 100n, 100n); + + expect(getUsableUTXOsFilter(state)(utxo)).toBe(false); + }); + + test('throws an error if UTXO output is a StakeableLockOut and the transferOut is not a TransferOutput', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + + const invalidUTXO = new Utxo( + new UTXOID(testUTXOID2, new Int(0)), + testAvaxAssetID, + new StakeableLockOut( + new BigIntPr(50n), + new StakeableLockIn( + new BigIntPr(2000000000n), + TransferableOutput.fromNative(testAvaxAssetID.toString(), 20n, [ + hexToBuffer('0x12345678901234578901234567890123457890'), + ]), + ), + ), + ); + + expect(() => getUsableUTXOsFilter(state)(invalidUTXO)).toThrow( + IncorrectStakeableLockOutError, + ); + }); + + test('returns `false` if UTXO output is not a TransferOutput or a StakeableLockOut', () => { + expect( + getUsableUTXOsFilter(getInitialReducerState())(getNotTransferOutput()), + ).toBe(false); + }); + }); + + it('should handle verified usable AVAX UTXOs', () => { + const toBurn = new Map([[testContext.avaxAssetID, 4_900n]]); + const toStake = new Map([[testContext.avaxAssetID, 4_900n]]); + + const initialState = getInitialReducerState({ + fromAddresses: addressesFromBytes(fromAddressBytes), + excessAVAX: 0n, + toBurn, + toStake, + utxos: [getValidUtxo(new BigIntPr(10_000n))], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + const state = useUnlockedUTXOs(initialState, spendHelper, testContext); + const { inputs } = spendHelper.getInputsOutputs(); + + expect(state.excessAVAX).toEqual(10_000n - 4_900n - 4_900n); + expect(state.ownerOverride).toBe(null); + expect(inputs).toHaveLength(1); + expect(inputs[0].getAssetId()).toEqual(testContext.avaxAssetID); + }); + + it('should skip other verified usable UTXOs with no toBurn or toStake match', () => { + const toBurn = new Map([[testContext.avaxAssetID, 4_900n]]); + const toStake = new Map([[testContext.avaxAssetID, 4_900n]]); + + const initialState = getInitialReducerState({ + fromAddresses: addressesFromBytes(fromAddressBytes), + excessAVAX: 0n, + toBurn, + toStake, + utxos: [ + getValidUtxo(new BigIntPr(10_000n)), + getValidUtxo(new BigIntPr(5_000n), Id.fromString('testasset')), + ], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + useUnlockedUTXOs(initialState, spendHelper, testContext); + const { inputs, inputUTXOs } = spendHelper.getInputsOutputs(); + + // Should only be the AVAX UTXO + expect(inputUTXOs).toHaveLength(1); + expect(inputs).toHaveLength(1); + expect(inputs[0].getAssetId()).not.toEqual('testasset'); + }); + + it('should consume other verified usable UTXOs with a toBurn or toStake match', () => { + const testAssetId = Id.fromString('testasset'); + const testAssetId2 = Id.fromString('testasset2'); + const toBurn = new Map([ + [testContext.avaxAssetID, 4_900n], + [testAssetId.toString(), 1_900n], + [testAssetId2.toString(), 100n], + ]); + const toStake = new Map([ + [testContext.avaxAssetID, 4_900n], + [testAssetId.toString(), 1_900n], + ]); + + const initialState = getInitialReducerState({ + fromAddresses: addressesFromBytes(fromAddressBytes), + excessAVAX: 0n, + toBurn, + toStake, + utxos: [ + getValidUtxo(new BigIntPr(10_000n)), + getValidUtxo(new BigIntPr(5_000n), testAssetId), + getValidUtxo(new BigIntPr(100n), testAssetId2), + ], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + useUnlockedUTXOs(initialState, spendHelper, testContext); + const { changeOutputs, inputs, inputUTXOs } = + spendHelper.getInputsOutputs(); + + expect(inputUTXOs).toHaveLength(3); + expect(inputs).toHaveLength(3); + + // Only expect 1 for now. The AVAX UTXOs aren't added as part of this reducer. + // Only testAssetId is given back change. testAssetId2 is consumed fully with no change. + expect(changeOutputs).toHaveLength(1); + + expect(changeOutputs[0].amount()).toEqual(5_000n - 1_900n - 1_900n); + }); + + it('should ignore UTXOs that signatures do not match', () => { + const toBurn = new Map([[testContext.avaxAssetID, 4_900n]]); + const toStake = new Map([[testContext.avaxAssetID, 4_900n]]); + + const initialState = getInitialReducerState({ + fromAddresses: [ + Address.fromString('P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg'), + ], + excessAVAX: 0n, + toBurn, + toStake, + utxos: [getValidUtxo(new BigIntPr(10_000n))], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + expect(() => + useUnlockedUTXOs(initialState, spendHelper, testContext), + ).toThrow(NoSigMatchError); + }); +}); From 7e665504b2f5a71db03ee99c21996249fb6b117b Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Mon, 23 Sep 2024 15:28:51 -0600 Subject: [PATCH 32/39] test: add useSpendableLockedUTXOs test and examples updates --- examples/p-chain/etna/delegate.ts | 5 +- examples/p-chain/etna/utils/random-node-id.ts | 10 + examples/p-chain/etna/validate.ts | 9 +- .../useSpendableLockedUTXOs.test.ts | 253 ++++++++++++++++++ 4 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 examples/p-chain/etna/utils/random-node-id.ts create mode 100644 src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts diff --git a/examples/p-chain/etna/delegate.ts b/examples/p-chain/etna/delegate.ts index dc1ea92be..673a7319e 100644 --- a/examples/p-chain/etna/delegate.ts +++ b/examples/p-chain/etna/delegate.ts @@ -3,7 +3,7 @@ import { getEnvVars } from '../../utils/getEnvVars'; import { getEtnaContextFromURI } from './utils/etna-context'; const AMOUNT_TO_DELEGATE_AVAX: number = 1; -const DAYS_TO_DELEGATE: number = 21; +const DAYS_TO_DELEGATE: number = 14; const main = async () => { const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY } = getEnvVars(); @@ -22,7 +22,8 @@ const main = async () => { endTime.setDate(endTime.getDate() + DAYS_TO_DELEGATE); const end: bigint = BigInt(endTime.getTime() / 1_000); - const nodeId = 'NodeID-HKLp5269LH8DcrLvHPc2PHjGczBQD3td4'; + // TODO: Get this from an argument. + const nodeId = 'NodeID-MqgFXT8JhorbEW2LpTDGePBBhv55SSp3M'; const tx = pvm.e.newAddPermissionlessDelegatorTx( { diff --git a/examples/p-chain/etna/utils/random-node-id.ts b/examples/p-chain/etna/utils/random-node-id.ts new file mode 100644 index 000000000..819416748 --- /dev/null +++ b/examples/p-chain/etna/utils/random-node-id.ts @@ -0,0 +1,10 @@ +import { base58check } from '../../../../src/utils'; + +export const getRandomNodeId = (): string => { + const buffer = new Uint8Array(20); + const randomBuffer = crypto.getRandomValues(buffer); + + const nodeId = `NodeID-${base58check.encode(randomBuffer)}`; + + return nodeId; +}; diff --git a/examples/p-chain/etna/validate.ts b/examples/p-chain/etna/validate.ts index a8e24f5c5..29b446487 100644 --- a/examples/p-chain/etna/validate.ts +++ b/examples/p-chain/etna/validate.ts @@ -1,10 +1,13 @@ import { addTxSignatures, networkIDs, pvm, utils } from '../../../src'; import { getEnvVars } from '../../utils/getEnvVars'; import { getEtnaContextFromURI } from './utils/etna-context'; +import { getRandomNodeId } from './utils/random-node-id'; const AMOUNT_TO_VALIDATE_AVAX: number = 1; const DAYS_TO_VALIDATE: number = 21; +const nodeId = getRandomNodeId(); + const main = async () => { const { AVAX_PUBLIC_URL, P_CHAIN_ADDRESS, PRIVATE_KEY } = getEnvVars(); @@ -22,8 +25,6 @@ const main = async () => { endTime.setDate(endTime.getDate() + DAYS_TO_VALIDATE); const end: bigint = BigInt(endTime.getTime() / 1_000); - const nodeId = 'NodeID-HKLp5269LH8DcrLvNDoJquQs2w1LwLCga'; - const publicKey = utils.hexToBuffer( '0x8f95423f7142d00a48e1014a3de8d28907d420dc33b3052a6dee03a3f2941a393c2351e354704ca66a3fc29870282e15', ); @@ -58,4 +59,6 @@ const main = async () => { return pvmApi.issueSignedTx(tx.getSignedTx()); }; -main().then(console.log); +main() + .then(console.log) + .then(() => console.log('Validate node ID:', nodeId)); diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts new file mode 100644 index 000000000..53aa22020 --- /dev/null +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts @@ -0,0 +1,253 @@ +import { testContext } from '../../../../fixtures/context'; +import { + getUsableUTXOsFilter, + useSpendableLockedUTXOs, +} from './useSpendableLockedUTXOs'; +import { getInitialReducerState, getSpendHelper } from './fixtures/reducers'; +import { + getLockedUTXO, + getStakeableLockoutOutput, + testAvaxAssetID, + testOwnerXAddress, + testUTXOID1, + testUTXOID2, +} from '../../../../fixtures/transactions'; +import { + Address, + BigIntPr, + Id, + Int, + TransferableOutput, +} from '../../../../serializable'; +import { Utxo } from '../../../../serializable/avax/utxo'; +import { + StakeableLockIn, + StakeableLockOut, +} from '../../../../serializable/pvm'; +import { IncorrectStakeableLockOutError } from './errors'; +import { hexToBuffer } from '../../../../utils'; +import { UTXOID } from '../../../../serializable/avax'; +import { NoSigMatchError } from '../../../utils/calculateSpend/utils'; + +describe('useSpendableLockedUTXOs', () => { + describe('getUsableUTXOsFilter', () => { + test('returns `false` if UTXO output not a stakeable lockout', () => { + expect( + getUsableUTXOsFilter(getInitialReducerState())(getLockedUTXO()), + ).toBe(false); + }); + + test('returns `false` if UTXO output is a stakeable lockout but locktime is greater than minIssuanceTime', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + + const utxo = getStakeableLockoutOutput(testUTXOID1, 50n, 200n); + + expect(getUsableUTXOsFilter(state)(utxo)).toBe(false); + }); + + test('returns `false` if UTXO output is a stakeable lockout with valid locktime but not used in toStake', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 300n, + }, + }); + + const utxo = getStakeableLockoutOutput(testUTXOID1, 50n, 100n); + + expect(getUsableUTXOsFilter(state)(utxo)).toBe(false); + }); + + test('returns `true` if UTXO output is a stakeable lockout with valid locktime and used in toStake', () => { + const testAssetId = Id.fromString('testasset'); + + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + toStake: new Map([[testAssetId.toString(), 100n]]), + }); + + const utxo = getStakeableLockoutOutput( + testUTXOID1, + 50n, + 300n, + testAssetId, + ); + + expect(getUsableUTXOsFilter(state)(utxo)).toBe(true); + }); + + test('throws an error if UTXO output is a StakeableLockOut and the transferOut is not a TransferOutput', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + + const invalidUTXO = new Utxo( + new UTXOID(testUTXOID2, new Int(0)), + testAvaxAssetID, + new StakeableLockOut( + new BigIntPr(300n), + new StakeableLockIn( + new BigIntPr(2000000000n), + TransferableOutput.fromNative(testAvaxAssetID.toString(), 20n, [ + hexToBuffer('0x12345678901234578901234567890123457890'), + ]), + ), + ), + ); + + expect(() => getUsableUTXOsFilter(state)(invalidUTXO)).toThrow( + IncorrectStakeableLockOutError, + ); + }); + }); + + it('should ignore UTXOs that signatures do not match', () => { + const toBurn = new Map([[testContext.avaxAssetID, 4_900n]]); + const toStake = new Map([[testContext.avaxAssetID, 4_900n]]); + + const initialState = getInitialReducerState({ + fromAddresses: [ + Address.fromString('P-fuji1y50xa9363pn3d5gjhcz3ltp3fj6vq8x8a5txxg'), + ], + excessAVAX: 0n, + spendOptions: { + minIssuanceTime: 100n, + }, + toBurn, + toStake, + utxos: [getStakeableLockoutOutput(testUTXOID1, 10_000n, 300n)], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + expect(() => + useSpendableLockedUTXOs(initialState, spendHelper, testContext), + ).toThrow(NoSigMatchError); + }); + + it('should do nothing if UTXO has no remaining amount to stake', () => { + const toBurn = new Map(); + const toStake = new Map(); + + const initialState = getInitialReducerState({ + excessAVAX: 0n, + spendOptions: { + minIssuanceTime: 100n, + }, + toBurn, + toStake, + utxos: [getStakeableLockoutOutput(testUTXOID1, 10_000n, 300n)], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + const state = useSpendableLockedUTXOs( + initialState, + spendHelper, + testContext, + ); + const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = + spendHelper.getInputsOutputs(); + + expect(state).toEqual(initialState); + expect(changeOutputs).toHaveLength(0); + expect(inputs).toHaveLength(0); + expect(inputUTXOs).toHaveLength(0); + expect(stakeOutputs).toHaveLength(0); + }); + + it('should add stake outputs even with no UTXOs', () => { + const toBurn = new Map(); + const toStake = new Map([[testContext.avaxAssetID, 1_000n]]); + + const initialState = getInitialReducerState({ + excessAVAX: 0n, + spendOptions: { + minIssuanceTime: 100n, + }, + toBurn, + toStake, + utxos: [], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + useSpendableLockedUTXOs(initialState, spendHelper, testContext); + const { stakeOutputs } = spendHelper.getInputsOutputs(); + + expect(stakeOutputs).toHaveLength(1); + expect(stakeOutputs[0].assetId.toString()).toEqual(testContext.avaxAssetID); + expect(stakeOutputs[0].amount()).toEqual(1_000n); + }); + + it('should add spendable locked UTXO with change', () => { + const toBurn = new Map(); + const toStake = new Map([[testAvaxAssetID.toString(), 1_000n]]); + + const initialState = getInitialReducerState({ + fromAddresses: [testOwnerXAddress], + excessAVAX: 0n, + spendOptions: { + minIssuanceTime: 100n, + }, + toBurn, + toStake, + utxos: [ + getStakeableLockoutOutput(testUTXOID1, 10_000n, 300n, testAvaxAssetID), + ], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + useSpendableLockedUTXOs(initialState, spendHelper, testContext); + + const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = + spendHelper.getInputsOutputs(); + + expect(inputs).toHaveLength(1); + expect(inputUTXOs).toHaveLength(1); + expect(changeOutputs).toHaveLength(1); + expect(stakeOutputs).toHaveLength(1); + + expect(stakeOutputs[0].amount()).toEqual(1_000n); + expect(changeOutputs[0].amount()).toEqual(9_000n); + }); + + it('should add spendable locked UTXO without change', () => { + const toBurn = new Map(); + const toStake = new Map([[testAvaxAssetID.toString(), 1_000n]]); + + const initialState = getInitialReducerState({ + fromAddresses: [testOwnerXAddress], + excessAVAX: 0n, + spendOptions: { + minIssuanceTime: 100n, + }, + toBurn, + toStake, + utxos: [ + getStakeableLockoutOutput(testUTXOID1, 1_000n, 300n, testAvaxAssetID), + ], + }); + + const spendHelper = getSpendHelper({ toBurn, toStake }); + + useSpendableLockedUTXOs(initialState, spendHelper, testContext); + + const { changeOutputs, inputs, inputUTXOs, stakeOutputs } = + spendHelper.getInputsOutputs(); + + expect(inputs).toHaveLength(1); + expect(inputUTXOs).toHaveLength(1); + expect(changeOutputs).toHaveLength(0); + expect(stakeOutputs).toHaveLength(1); + + expect(stakeOutputs[0].amount()).toEqual(1_000n); + }); +}); From 7c0d056e782bbdb6da4352071cfddf8d55d880b3 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Tue, 24 Sep 2024 15:02:55 -0600 Subject: [PATCH 33/39] refactor: cleanup based on review feedback --- src/crypto/secp256k1.ts | 1 + .../spend-reducers/handleFeeAndChange.test.ts | 31 +++++----- .../spend-reducers/handleFeeAndChange.ts | 15 +++-- .../spend-reducers/useUnlockedUTXOs.ts | 32 +++++++---- src/vms/pvm/etna-builder/spend.ts | 8 +-- src/vms/pvm/etna-builder/spendHelper.test.ts | 16 ++---- src/vms/pvm/etna-builder/spendHelper.ts | 57 ++++++------------- 7 files changed, 71 insertions(+), 89 deletions(-) diff --git a/src/crypto/secp256k1.ts b/src/crypto/secp256k1.ts index 77a13f52d..891e17fcf 100644 --- a/src/crypto/secp256k1.ts +++ b/src/crypto/secp256k1.ts @@ -4,6 +4,7 @@ import * as secp from '@noble/secp256k1'; import { Address } from 'micro-eth-signer'; import { concatBytes, hexToBuffer } from '../utils/buffer'; +/** Number of bytes per signature */ export const SIGNATURE_LENGTH = 65; export function randomPrivateKey() { diff --git a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts index 059873e22..2f233833d 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.test.ts @@ -7,6 +7,12 @@ import { getInitialReducerState, getSpendHelper, } from './fixtures/reducers'; +import { + BigIntPr, + Id, + TransferOutput, + TransferableOutput, +} from '../../../../serializable'; describe('handleFeeAndChange', () => { test('throws an error if excessAVAX is less than the required fee', () => { @@ -25,15 +31,11 @@ describe('handleFeeAndChange', () => { const state = getInitialReducerState({ excessAVAX: 4n }); const spendHelper = getSpendHelper(); const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); - const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( - spendHelper, - 'calculateFeeWithTemporaryOutputComplexity', - ); + const calculateFeeSpy = jest.spyOn(spendHelper, 'calculateFee'); - expect(handleFeeAndChange(state, getSpendHelper(), testContext)).toEqual( - state, - ); - expect(calculateFeeWithTemporaryOutputComplexitySpy).not.toHaveBeenCalled(); + expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual(state); + expect(calculateFeeSpy).toHaveBeenCalledTimes(1); + expect(calculateFeeSpy).toHaveBeenCalledWith(); expect(addChangeOutputSpy).not.toHaveBeenCalled(); }); @@ -45,17 +47,18 @@ describe('handleFeeAndChange', () => { const spendHelper = getSpendHelper(); const addChangeOutputSpy = jest.spyOn(spendHelper, 'addChangeOutput'); - const calculateFeeWithTemporaryOutputComplexitySpy = jest.spyOn( - spendHelper, - 'calculateFeeWithTemporaryOutputComplexity', - ); + const calculateFeeSpy = jest.spyOn(spendHelper, 'calculateFee'); expect(handleFeeAndChange(state, spendHelper, testContext)).toEqual({ ...state, excessAVAX, }); - expect(calculateFeeWithTemporaryOutputComplexitySpy).toHaveBeenCalledTimes( - 1, + expect(calculateFeeSpy).toHaveBeenCalledTimes(2); + expect(calculateFeeSpy).toHaveBeenCalledWith( + new TransferableOutput( + Id.fromString(testContext.avaxAssetID), + new TransferOutput(new BigIntPr(0n), CHANGE_OWNERS), + ), ); expect(addChangeOutputSpy).toHaveBeenCalledTimes(1); diff --git a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts index 644b31b17..94f7f9a08 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/handleFeeAndChange.ts @@ -72,17 +72,16 @@ export const handleFeeAndChange: SpendReducerFunction = ( } else { // Calculate the fee with a temporary output complexity // as if we added the change output. - const requiredFeeWithChangeOutput = - spendHelper.calculateFeeWithTemporaryOutputComplexity( - new TransferableOutput( - Id.fromString(context.avaxAssetID), - new TransferOutput(new BigIntPr(0n), changeOwners), - ), - ); + const requiredFeeWithChangeOutput = spendHelper.calculateFee( + new TransferableOutput( + Id.fromString(context.avaxAssetID), + new TransferOutput(new BigIntPr(0n), changeOwners), + ), + ); // If the excess AVAX is greater than the new fee, add a change output. // Otherwise, ignore and burn the excess because it can't be returned - // (ie we can't pay the fee to return the excess). + // (ie there is no point in adding a change output if you can't afford to add it). if (state.excessAVAX > requiredFeeWithChangeOutput) { spendHelper.addChangeOutput( new TransferableOutput( diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts index 8b9c4be37..1883a83ea 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts @@ -63,19 +63,31 @@ export const useUnlockedUTXOs: SpendReducerFunction = ( ); // 3. Split verified usable UTXOs into AVAX assetId UTXOs and other assetId UTXOs. - const [otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs] = - verifiedUsableUTXOs.reduce( - (result, { sigData, data: utxo }) => { - if (utxo.assetId.value() === context.avaxAssetID) { - return [result[0], [...result[1], { sigData, data: utxo }]]; + const { otherVerifiedUsableUTXOs, avaxVerifiedUsableUTXOs } = + verifiedUsableUTXOs.reduce<{ + avaxVerifiedUsableUTXOs: typeof verifiedUsableUTXOs; + otherVerifiedUsableUTXOs: typeof verifiedUsableUTXOs; + }>( + (result, verifiedUsableUTXO) => { + if (verifiedUsableUTXO.data.assetId.value() === context.avaxAssetID) { + return { + ...result, + avaxVerifiedUsableUTXOs: [ + ...result.avaxVerifiedUsableUTXOs, + verifiedUsableUTXO, + ], + }; } - return [[...result[0], { sigData, data: utxo }], result[1]]; + return { + ...result, + otherVerifiedUsableUTXOs: [ + ...result.otherVerifiedUsableUTXOs, + verifiedUsableUTXO, + ], + }; }, - [[], []] as [ - other: typeof verifiedUsableUTXOs, - avax: typeof verifiedUsableUTXOs, - ], + { otherVerifiedUsableUTXOs: [], avaxVerifiedUsableUTXOs: [] }, ); // 4. Handle all the non-AVAX asset UTXOs first. diff --git a/src/vms/pvm/etna-builder/spend.ts b/src/vms/pvm/etna-builder/spend.ts index a68b39c0e..3471e729e 100644 --- a/src/vms/pvm/etna-builder/spend.ts +++ b/src/vms/pvm/etna-builder/spend.ts @@ -58,7 +58,7 @@ export type SpendProps = Readonly<{ */ ownerOverride?: OutputOwners | null; /** - * Whether to consolidate outputs. + * Whether to consolidate change and stake outputs. * * @default false */ @@ -100,7 +100,7 @@ export type SpendProps = Readonly<{ */ export const spend = ( { - excessAVAX: _excessAVAX = 0n, + excessAVAX = 0n, fromAddresses, initialComplexity, ownerOverride, @@ -116,7 +116,6 @@ export const spend = ( try { const changeOwners = ownerOverride || OutputOwners.fromNative(spendOptions.changeAddresses); - const excessAVAX: bigint = _excessAVAX; const spendHelper = new SpendHelper({ changeOutputs: [], @@ -143,9 +142,6 @@ export const spend = ( const spendReducerFunctions: readonly SpendReducerFunction[] = [ ...spendReducers, - // useSpendableLockedUTXOs, - // TODO: Should we just default include this? Used on every builder. - // useUnlockedUTXOs, verifyAssetsConsumed, handleFeeAndChange, // Consolidation and sorting happens in the SpendHelper. diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 211bd517c..50c42c982 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -61,10 +61,6 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { stakeOutputs: [], }); - spendHelper.addOutputComplexity(transferableOutput()); - - expect(spendHelper.calculateFee()).toBe(339n); - const inputUtxo = utxo(); const inputTransferableInput = transferableInput(); @@ -72,7 +68,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [], - fee: 1251n, + fee: 942n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [], @@ -84,7 +80,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [changeOutput], - fee: 1560n, + fee: 1251n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [], @@ -96,7 +92,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.getInputsOutputs()).toEqual({ changeOutputs: [changeOutput], - fee: 1869n, + fee: 1560n, inputs: [inputTransferableInput], inputUTXOs: [inputUtxo], stakeOutputs: [stakeOutput], @@ -397,9 +393,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { const temporaryOutput = transferableOutput(); - expect( - spendHelper.calculateFeeWithTemporaryOutputComplexity(temporaryOutput), - ).toBeGreaterThan(originalFee); + expect(spendHelper.calculateFee(temporaryOutput)).toBeGreaterThan( + originalFee, + ); expect(spendHelper.calculateFee()).toBe(originalFee); }); diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index d7f4bcabe..54699f311 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -41,8 +41,6 @@ export class SpendHelper { private changeOutputs: readonly TransferableOutput[]; private inputs: readonly TransferableInput[]; - private inputComplexity: Dimensions = createEmptyDimensions(); - private outputComplexity: Dimensions = createEmptyDimensions(); private stakeOutputs: readonly TransferableOutput[]; private inputUTXOs: readonly Utxo[] = []; @@ -78,13 +76,6 @@ export class SpendHelper { * @returns {SpendHelper} The current instance of SpendHelper for chaining. */ addInput(utxo: Utxo, transferableInput: TransferableInput): SpendHelper { - const newInputComplexity = getInputComplexity([transferableInput]); - - this.inputComplexity = addDimensions( - this.inputComplexity, - newInputComplexity, - ); - this.inputs = [...this.inputs, transferableInput]; this.inputUTXOs = [...this.inputUTXOs, utxo]; @@ -118,29 +109,19 @@ export class SpendHelper { } /** - * Adds a transferable output to the SpendHelper. - * - * @param {TransferableOutput} transferableOutput - The transferable output to be added. - * @returns {SpendHelper} The current instance of SpendHelper for chaining. + * When computing the complexity/fee of a transaction that needs change but doesn't yet have + * a corresponding change output, `additionalComplexity` may be used to calculate the complexity + * and therefore the fee as if the change output was already added. */ - addOutputComplexity(transferableOutput: TransferableOutput): SpendHelper { - const newOutputComplexity = getOutputComplexity([transferableOutput]); - - this.outputComplexity = addDimensions( - this.outputComplexity, - newOutputComplexity, - ); - - return this; - } - - private getComplexity(): Dimensions { + private getComplexity( + additionalComplexity: Dimensions = createEmptyDimensions(), + ): Dimensions { return addDimensions( this.initialComplexity, getInputComplexity(this.inputs), getOutputComplexity(this.changeOutputs), getOutputComplexity(this.stakeOutputs), - this.outputComplexity, + additionalComplexity, ); } @@ -231,30 +212,24 @@ export class SpendHelper { /** * Calculates the fee for the SpendHelper based on its complexity and gas price. + * Provide an empty change output as a parameter to calculate the fee as if the change output was already added. * + * @param {TransferableOutput} additionalOutput - The change output that has not yet been added to the SpendHelper. * @returns {bigint} The fee for the SpendHelper. */ - calculateFee(): bigint { + calculateFee(additionalOutput?: TransferableOutput): bigint { this.consolidateOutputs(); - const gas = dimensionsToGas(this.getComplexity(), this.weights); + const gas = dimensionsToGas( + this.getComplexity( + additionalOutput ? getOutputComplexity([additionalOutput]) : undefined, + ), + this.weights, + ); return gas * this.gasPrice; } - calculateFeeWithTemporaryOutputComplexity( - transferableOutput: TransferableOutput, - ): bigint { - const oldOutputComplexity = this.outputComplexity; - this.addOutputComplexity(transferableOutput); - - const fee = this.calculateFee(); - - this.outputComplexity = oldOutputComplexity; - - return fee; - } - /** * Determines if a change output with a matching asset ID and output owners exists. * From b3c6bfd60d7b63ea73cd354b1109920e17a2ad52 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 26 Sep 2024 11:04:31 -0600 Subject: [PATCH 34/39] refactor: etna builder importtx with test --- src/vms/pvm/etna-builder/builder.test.ts | 76 +++++++++++++++++++ src/vms/pvm/etna-builder/builder.ts | 93 ++++++++++++------------ 2 files changed, 122 insertions(+), 47 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index 14d9e2b49..6025c0aec 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -1,5 +1,7 @@ import { testContext as _testContext } from '../../../fixtures/context'; import { + getLockedUTXO, + getNotTransferOutput, getTransferableInputForTest, getTransferableOutForTest, getValidUtxo, @@ -936,4 +938,78 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { expectTxs(unsignedTx.getTx(), expectedTx); }); }); + + describe('ImportTx', () => { + it('should create an ImportTx with both AVAX and non-AVAX assets', () => { + const utxos = [ + getLockedUTXO(), // Locked and should be ignored. + getNotTransferOutput(), // Invalid and should be ignored. + // AVAX Assets + getValidUtxo(new BigIntPr(BigInt(35 * 1e9)), testAvaxAssetID), + getValidUtxo(new BigIntPr(BigInt(28 * 1e9)), testAvaxAssetID), + // Non-AVAX Assets (Jupiter) + getValidUtxo(new BigIntPr(BigInt(15 * 1e9)), Id.fromString('jupiter')), + getValidUtxo(new BigIntPr(BigInt(11 * 1e9)), Id.fromString('jupiter')), + // Non-AVAX Asset (Mars) + getValidUtxo(new BigIntPr(BigInt(9 * 1e9)), Id.fromString('mars')), + ]; + + const unsignedTx = newImportTx( + { + fromAddressesBytes, + sourceChainId: testContext.cBlockchainID, + toAddresses: [testAddress1], + utxos, + }, + testContext, + ); + + const { baseTx, ins: importedIns } = unsignedTx.getTx() as ImportTx; + const { inputs, outputs } = baseTx; + + const [amountConsumed, expectedAmountConsumed, expectedFee] = + checkFeeIsCorrect({ + unsignedTx, + inputs, + outputs, + additionalInputs: importedIns, + }); + + expect(amountConsumed).toEqual(expectedAmountConsumed); + + const expectedTx = new ImportTx( + AvaxBaseTx.fromNative( + testContext.networkID, + testContext.pBlockchainID, + [ + // "Other" assets are first. Sorted by TransferableInput.compare + TransferableOutput.fromNative('mars', BigInt(9 * 1e9), [ + testAddress1, + ]), + TransferableOutput.fromNative('jupiter', BigInt(26 * 1e9), [ + testAddress1, + ]), + // AVAX come last. + TransferableOutput.fromNative( + testContext.avaxAssetID, + BigInt((35 + 28) * 1e9) - expectedFee, + [testAddress1], + ), + ], + [], + new Uint8Array(), + ), + Id.fromString(testContext.cBlockchainID), + [ + TransferableInput.fromUtxoAndSigindicies(utxos[2], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[3], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[4], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[5], [0]), + TransferableInput.fromUtxoAndSigindicies(utxos[6], [0]), + ], + ); + + expectTxs(unsignedTx.getTx(), expectedTx); + }); + }); }); diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 07cffdebe..507f88442 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -9,6 +9,7 @@ import { PlatformChainID, PrimaryNetworkID, } from '../../../constants/networkIDs'; +import type { TransferOutput } from '../../../serializable'; import { Input, NodeId, @@ -233,51 +234,55 @@ export const newImportTx: TxBuilderFn = ( const fromAddresses = addressesFromBytes(fromAddressesBytes); const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); - const importedInputs: TransferableInput[] = []; - const importedAmounts: Record = {}; - - for (const utxo of utxos) { - const out = utxo.output; - - if (!isTransferOut(out)) { - continue; - } - - const { sigIndicies: inputSigIndices } = - matchOwners( - utxo.getOutputOwners(), - fromAddresses, - defaultedOptions.minIssuanceTime, - ) || {}; - - if (inputSigIndices === undefined) { - // We couldn't spend this UTXO, so we skip to the next one. - continue; - } - - importedInputs.push( - new TransferableInput( - utxo.utxoId, - utxo.assetId, - new TransferInput( - out.amt, - new Input(inputSigIndices.map((value) => new Int(value))), - ), - ), + const { importedInputs, importedAmounts } = utxos + .filter((utxo): utxo is Utxo => isTransferOut(utxo.output)) + .reduce<{ + importedInputs: TransferableInput[]; + importedAmounts: Record; + }>( + (acc, utxo) => { + const { sigIndicies: inputSigIndices } = + matchOwners( + utxo.getOutputOwners(), + fromAddresses, + defaultedOptions.minIssuanceTime, + ) || {}; + + if (inputSigIndices === undefined) { + // We couldn't spend this UTXO, so we skip to the next one. + return acc; + } + + const assetId = utxo.getAssetId(); + + return { + importedInputs: [ + ...acc.importedInputs, + new TransferableInput( + utxo.utxoId, + utxo.assetId, + new TransferInput( + utxo.output.amt, + new Input(inputSigIndices.map((value) => new Int(value))), + ), + ), + ], + importedAmounts: { + ...acc.importedAmounts, + [assetId]: + (acc.importedAmounts[assetId] ?? 0n) + utxo.output.amount(), + }, + }; + }, + { importedInputs: [], importedAmounts: {} }, ); - const assetId = utxo.getAssetId(); - - importedAmounts[assetId] = (importedAmounts[assetId] ?? 0n) + out.amount(); - } - if (importedInputs.length === 0) { throw new Error('no UTXOs available to import'); } const importedAvax = importedAmounts[context.avaxAssetID]; - importedInputs.sort(TransferableInput.compare); const addressMaps = AddressMaps.fromTransferableInputs( importedInputs, utxos, @@ -285,14 +290,9 @@ export const newImportTx: TxBuilderFn = ( fromAddressesBytes, ); - const outputs: TransferableOutput[] = []; - - for (const [assetID, amount] of Object.entries(importedAmounts)) { - if (assetID === context.avaxAssetID) { - continue; - } - - outputs.push( + const outputs: TransferableOutput[] = Object.entries(importedAmounts) + .filter(([assetID]) => assetID !== context.avaxAssetID) + .map(([assetID, amount]) => TransferableOutput.fromNative( assetID, amount, @@ -301,7 +301,6 @@ export const newImportTx: TxBuilderFn = ( threshold, ), ); - } const memoComplexity = getMemoComplexity(defaultedOptions); @@ -341,7 +340,7 @@ export const newImportTx: TxBuilderFn = ( new Bytes(defaultedOptions.memo), ), Id.fromString(sourceChainId), - importedInputs, + importedInputs.sort(TransferableInput.compare), ), inputUTXOs, addressMaps, From 141294dfdc3f0fd92f21815aef5681f655d7742e Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 26 Sep 2024 11:29:03 -0600 Subject: [PATCH 35/39] fix: only allow AVAX assets on importtx --- src/vms/pvm/etna-builder/builder.test.ts | 16 ++++------------ src/vms/pvm/etna-builder/builder.ts | 7 ++++++- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index 6025c0aec..44ec876d0 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -940,7 +940,7 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { }); describe('ImportTx', () => { - it('should create an ImportTx with both AVAX and non-AVAX assets', () => { + it('should create an ImportTx with only AVAX and not non-AVAX assets', () => { const utxos = [ getLockedUTXO(), // Locked and should be ignored. getNotTransferOutput(), // Invalid and should be ignored. @@ -982,14 +982,9 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { testContext.networkID, testContext.pBlockchainID, [ - // "Other" assets are first. Sorted by TransferableInput.compare - TransferableOutput.fromNative('mars', BigInt(9 * 1e9), [ - testAddress1, - ]), - TransferableOutput.fromNative('jupiter', BigInt(26 * 1e9), [ - testAddress1, - ]), - // AVAX come last. + // Only AVAX asset here. + // _If_ we did p-chain did support other assets, they would come first, + // sorted by TransferableInput.compare. TransferableOutput.fromNative( testContext.avaxAssetID, BigInt((35 + 28) * 1e9) - expectedFee, @@ -1003,9 +998,6 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { [ TransferableInput.fromUtxoAndSigindicies(utxos[2], [0]), TransferableInput.fromUtxoAndSigindicies(utxos[3], [0]), - TransferableInput.fromUtxoAndSigindicies(utxos[4], [0]), - TransferableInput.fromUtxoAndSigindicies(utxos[5], [0]), - TransferableInput.fromUtxoAndSigindicies(utxos[6], [0]), ], ); diff --git a/src/vms/pvm/etna-builder/builder.ts b/src/vms/pvm/etna-builder/builder.ts index 507f88442..0b40e13e0 100644 --- a/src/vms/pvm/etna-builder/builder.ts +++ b/src/vms/pvm/etna-builder/builder.ts @@ -235,7 +235,12 @@ export const newImportTx: TxBuilderFn = ( const defaultedOptions = defaultSpendOptions(fromAddressesBytes, options); const { importedInputs, importedAmounts } = utxos - .filter((utxo): utxo is Utxo => isTransferOut(utxo.output)) + .filter( + (utxo): utxo is Utxo => + isTransferOut(utxo.output) && + // Currently - only AVAX is allowed to be imported to the P-Chain + utxo.assetId.toString() === context.avaxAssetID, + ) .reduce<{ importedInputs: TransferableInput[]; importedAmounts: Record; From 94e1361a20dc5663c2e2e25fa467451299ec8f15 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Thu, 26 Sep 2024 16:39:34 -0600 Subject: [PATCH 36/39] fix: address review feedback and fix adding stakeouts --- .../useSpendableLockedUTXOs.test.ts | 24 ---- .../spend-reducers/useSpendableLockedUTXOs.ts | 28 +---- .../spend-reducers/useUnlockedUTXOs.ts | 40 +++--- src/vms/pvm/etna-builder/spendHelper.test.ts | 114 +++++++++--------- src/vms/pvm/etna-builder/spendHelper.ts | 16 ++- src/vms/pvm/txs/fee/calculator.ts | 2 - src/vms/pvm/txs/fee/complexity.ts | 1 - 7 files changed, 98 insertions(+), 127 deletions(-) diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts index 53aa22020..b83793049 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.test.ts @@ -162,30 +162,6 @@ describe('useSpendableLockedUTXOs', () => { expect(stakeOutputs).toHaveLength(0); }); - it('should add stake outputs even with no UTXOs', () => { - const toBurn = new Map(); - const toStake = new Map([[testContext.avaxAssetID, 1_000n]]); - - const initialState = getInitialReducerState({ - excessAVAX: 0n, - spendOptions: { - minIssuanceTime: 100n, - }, - toBurn, - toStake, - utxos: [], - }); - - const spendHelper = getSpendHelper({ toBurn, toStake }); - - useSpendableLockedUTXOs(initialState, spendHelper, testContext); - const { stakeOutputs } = spendHelper.getInputsOutputs(); - - expect(stakeOutputs).toHaveLength(1); - expect(stakeOutputs[0].assetId.toString()).toEqual(testContext.avaxAssetID); - expect(stakeOutputs[0].amount()).toEqual(1_000n); - }); - it('should add spendable locked UTXO with change', () => { const toBurn = new Map(); const toStake = new Map([[testAvaxAssetID.toString(), 1_000n]]); diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts index da42cf8a9..623937375 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts @@ -33,7 +33,7 @@ export const getUsableUTXOsFilter = } // 1b. Ensure UTXO is stakeable. - if (!(state.spendOptions.minIssuanceTime < utxo.output.getLocktime())) { + if (state.spendOptions.minIssuanceTime >= utxo.output.getLocktime()) { return false; } @@ -43,7 +43,7 @@ export const getUsableUTXOsFilter = } // 1d. Filter out UTXOs that aren't needed for staking. - if ((state.toStake.get(utxo.assetId.value()) ?? 0n) === 0n) { + if (!state.toStake.has(utxo.assetId.value())) { return false; } @@ -85,19 +85,14 @@ export const useSpendableLockedUTXOs: SpendReducerFunction = ( utxo.utxoId, utxo.assetId, new StakeableLockIn( - // StakeableLockOut new BigIntPr(utxoInfo.stakeableLocktime), - TransferInput.fromNative( - // TransferOutput - utxoInfo.amount, - sigData.sigIndicies, - ), + TransferInput.fromNative(utxoInfo.amount, sigData.sigIndicies), ), ), ); // 3c. Consume the locked asset and get the remaining amount. - const remainingAmount = spendHelper.consumeLockedAsset( + const [remainingAmount] = spendHelper.consumeLockedAsset( utxoInfo.assetId, utxoInfo.amount, ); @@ -133,20 +128,5 @@ export const useSpendableLockedUTXOs: SpendReducerFunction = ( } } - // 4. Add all remaining stake amounts assuming they are unlocked. - for (const [assetId, amount] of state.toStake) { - if (amount === 0n) { - continue; - } - - spendHelper.addStakedOutput( - TransferableOutput.fromNative( - assetId, - amount, - state.spendOptions.changeAddresses, - ), - ); - } - return state; }; diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts index 1883a83ea..c72498c34 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts @@ -90,6 +90,12 @@ export const useUnlockedUTXOs: SpendReducerFunction = ( { otherVerifiedUsableUTXOs: [], avaxVerifiedUsableUTXOs: [] }, ); + const changeOwner = OutputOwners.fromNative( + state.spendOptions.changeAddresses, + 0n, + 1, + ); + // 4. Handle all the non-AVAX asset UTXOs first. for (const { sigData, data: utxo } of otherVerifiedUsableUTXOs) { const utxoInfo = getUtxoInfo(utxo); @@ -114,30 +120,27 @@ export const useUnlockedUTXOs: SpendReducerFunction = ( ); // 4c. Consume the asset and get the remaining amount. - const remainingAmount = spendHelper.consumeAsset( + const [remainingAmount, amountToStake] = spendHelper.consumeAsset( utxoInfo.assetId, utxoInfo.amount, ); // 4d. If "amountToStake" is greater than 0, add the stake output. - // TODO: Implement or determine if needed. + if (amountToStake > 0n) { + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new TransferOutput(new BigIntPr(amountToStake), changeOwner), + ), + ); + } // 4e. Add the change output if there is any remaining amount. if (remainingAmount > 0n) { spendHelper.addChangeOutput( new TransferableOutput( utxo.assetId, - new TransferableOutput( - utxo.assetId, - new TransferOutput( - new BigIntPr(remainingAmount), - OutputOwners.fromNative( - state.spendOptions.changeAddresses, - 0n, - 1, - ), - ), - ), + new TransferOutput(new BigIntPr(remainingAmount), changeOwner), ), ); } @@ -170,11 +173,20 @@ export const useUnlockedUTXOs: SpendReducerFunction = ( ), ); - const remainingAmount = spendHelper.consumeAsset( + const [remainingAmount, amountToStake] = spendHelper.consumeAsset( context.avaxAssetID, utxoInfo.amount, ); + if (amountToStake > 0n) { + spendHelper.addStakedOutput( + new TransferableOutput( + utxo.assetId, + new TransferOutput(new BigIntPr(amountToStake), changeOwner), + ), + ); + } + excessAVAX += remainingAmount; // The ownerOverride is no longer needed. Clear it. diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 50c42c982..512303ae5 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -167,65 +167,65 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { expect(spendHelper.shouldConsumeAsset('asset')).toBe(false); }); + }); - describe('SpendHelper.consumeLockedAsset', () => { - const testCases = [ - { - description: 'consumes the full amount', - toStake: new Map([['asset', 1n]]), - asset: 'asset', - amount: 1n, - expected: 0n, - }, - { - description: 'consumes a partial amount', - toStake: new Map([['asset', 1n]]), - asset: 'asset', - amount: 2n, - expected: 1n, - }, - { - description: 'consumes nothing', - toStake: new Map([['asset', 1n]]), - asset: 'asset', - amount: 0n, - expected: 0n, - }, - { - description: 'consumes nothing when asset not in toStake', - toStake: new Map(), - asset: 'asset', - amount: 1n, - expected: 1n, - }, - { - description: 'consumes nothing when asset in toStake with 0 value', - toStake: new Map([['asset', 0n]]), - asset: 'asset', - amount: 1n, - expected: 1n, - }, - ]; - - test.each(testCases)( - '$description', - ({ toStake, asset, amount, expected }) => { - const spendHelper = new SpendHelper({ - ...DEFAULT_PROPS, - toStake, - }); - - expect(spendHelper.consumeLockedAsset(asset, amount)).toBe(expected); - }, - ); + describe('SpendHelper.consumeLockedAsset', () => { + const testCases = [ + { + description: 'consumes the full amount', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 1n, + expected: 0n, + }, + { + description: 'consumes a partial amount', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 2n, + expected: 1n, + }, + { + description: 'consumes nothing', + toStake: new Map([['asset', 1n]]), + asset: 'asset', + amount: 0n, + expected: 0n, + }, + { + description: 'consumes nothing when asset not in toStake', + toStake: new Map(), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + { + description: 'consumes nothing when asset in toStake with 0 value', + toStake: new Map([['asset', 0n]]), + asset: 'asset', + amount: 1n, + expected: 1n, + }, + ]; - test('throws an error when amount is negative', () => { - const spendHelper = new SpendHelper(DEFAULT_PROPS); + test.each(testCases)( + '$description', + ({ toStake, asset, amount, expected }) => { + const spendHelper = new SpendHelper({ + ...DEFAULT_PROPS, + toStake, + }); - expect(() => { - spendHelper.consumeLockedAsset('asset', -1n); - }).toThrow('Amount to consume must be greater than or equal to 0'); - }); + expect(spendHelper.consumeLockedAsset(asset, amount)[0]).toBe(expected); + }, + ); + + test('throws an error when amount is negative', () => { + const spendHelper = new SpendHelper(DEFAULT_PROPS); + + expect(() => { + spendHelper.consumeLockedAsset('asset', -1n); + }).toThrow('Amount to consume must be greater than or equal to 0'); }); }); @@ -284,7 +284,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { toBurn, }); - expect(spendHelper.consumeAsset(asset, amount)).toBe(expected); + expect(spendHelper.consumeAsset(asset, amount)[0]).toBe(expected); }, ); diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index 54699f311..bc1fa3e45 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -160,9 +160,12 @@ export class SpendHelper { * * @param {string} assetId - The ID of the asset to consume. * @param {bigint} amount - The amount of the asset to consume. - * @returns {bigint} The remaining amount of the asset after consumption. + * @returns A tuple of the remaining amount in the first position and the amount to stake in the second position. */ - consumeLockedAsset(assetId: string, amount: bigint): bigint { + consumeLockedAsset( + assetId: string, + amount: bigint, + ): [remainingAmount: bigint, amountToStake: bigint] { if (amount < 0n) { throw new Error('Amount to consume must be greater than or equal to 0'); } @@ -179,7 +182,7 @@ export class SpendHelper { this.toStake.set(assetId, remainingAmountToStake - amountToStake); - return amount - amountToStake; + return [amount - amountToStake, amountToStake]; } /** @@ -187,9 +190,12 @@ export class SpendHelper { * * @param {string} assetId - The ID of the asset to consume. * @param {bigint} amount - The amount of the asset to consume. - * @returns {bigint} The remaining amount of the asset after consumption. + * @returns A tuple of the remaining amount in the first position and the amount to stake in the second position. */ - consumeAsset(assetId: string, amount: bigint): bigint { + consumeAsset( + assetId: string, + amount: bigint, + ): [remainingAmount: bigint, amountToStake: bigint] { if (amount < 0n) { throw new Error('Amount to consume must be greater than or equal to 0'); } diff --git a/src/vms/pvm/txs/fee/calculator.ts b/src/vms/pvm/txs/fee/calculator.ts index c897f0b33..da8139ec1 100644 --- a/src/vms/pvm/txs/fee/calculator.ts +++ b/src/vms/pvm/txs/fee/calculator.ts @@ -10,8 +10,6 @@ import { getTxComplexity } from './complexity'; * transaction must pay for valid inclusion into a block. */ export const calculateFee = ( - // TODO: Do we need this to be UnsignedTx? - // If so, we can use .getTx() to get the Transaction. tx: Transaction, weights: Dimensions, price: bigint, diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 4d5891d63..b2adfb3b8 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -188,7 +188,6 @@ export const getOwnerComplexity = (outputOwners: OutputOwners): Dimensions => { * It does not include the typeID of the credential. */ export const getAuthComplexity = (input: Serializable): Dimensions => { - // TODO: Not a fan of this. May be better to re-type `subnetAuth` as `Input` in `AddSubnetValidatorTx`? if (!(input instanceof Input)) { throw new Error( 'Unable to calculate auth complexity of transaction. Expected Input as subnet auth.', From 6f2979f0cab0676ba5c7b5fc102327e0e51ea47e Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 27 Sep 2024 08:58:29 -0600 Subject: [PATCH 37/39] fix: usable unlocked utxos --- .../spend-reducers/useUnlockedUTXOs.test.ts | 22 +++++++++++++++---- .../spend-reducers/useUnlockedUTXOs.ts | 17 +++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts index 58fe81941..ad85fafc8 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.test.ts @@ -30,10 +30,24 @@ import { NoSigMatchError } from '../../../utils/calculateSpend/utils'; describe('useUnlockedUTXOs', () => { describe('getUsableUTXOsFilter', () => { - test('returns `true` if UTXO output is a TransferOutput', () => { - expect( - getUsableUTXOsFilter(getInitialReducerState())(getLockedUTXO()), - ).toBe(true); + test('returns `true` if UTXO output is a TransferOutput and the locktime is less than the minIssuanceTime', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + const utxo = getValidUtxo(); + expect(getUsableUTXOsFilter(state)(utxo)).toBe(true); + }); + + test('returns `false` if UTXO output is a TransferOutput and the locktime is equal or greater than the minIssuanceTime', () => { + const state = getInitialReducerState({ + spendOptions: { + minIssuanceTime: 100n, + }, + }); + const utxo = getLockedUTXO(new BigIntPr(100n), 100n); + expect(getUsableUTXOsFilter(state)(utxo)).toBe(false); }); test('returns `true` if UTXO output is a StakeableLockOut and the locktime is less than the minIssuanceTime', () => { diff --git a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts index c72498c34..c95406269 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useUnlockedUTXOs.ts @@ -27,19 +27,18 @@ export const getUsableUTXOsFilter = ( utxo: Utxo, ): utxo is Utxo> => { - if (isTransferOut(utxo.output)) { - return true; + if (!(isStakeableLockOut(utxo.output) || isTransferOut(utxo.output))) { + return false; } - if (isStakeableLockOut(utxo.output)) { - if (!isTransferOut(utxo.output.transferOut)) { - throw IncorrectStakeableLockOutError; - } - - return utxo.output.getLocktime() < state.spendOptions.minIssuanceTime; + if ( + isStakeableLockOut(utxo.output) && + !isTransferOut(utxo.output.transferOut) + ) { + throw IncorrectStakeableLockOutError; } - return false; + return utxo.output.getLocktime() < state.spendOptions.minIssuanceTime; }; export const useUnlockedUTXOs: SpendReducerFunction = ( From ddefe8c1bfef0c6077e41cfa4a257494a9be40b0 Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 27 Sep 2024 10:07:01 -0600 Subject: [PATCH 38/39] refactor: rename some spend helper methods --- .../spend-reducers/useSpendableLockedUTXOs.ts | 2 +- src/vms/pvm/etna-builder/spendHelper.test.ts | 20 ++++++++++++------- src/vms/pvm/etna-builder/spendHelper.ts | 12 +++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts index 623937375..4f2870230 100644 --- a/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts +++ b/src/vms/pvm/etna-builder/spend-reducers/useSpendableLockedUTXOs.ts @@ -92,7 +92,7 @@ export const useSpendableLockedUTXOs: SpendReducerFunction = ( ); // 3c. Consume the locked asset and get the remaining amount. - const [remainingAmount] = spendHelper.consumeLockedAsset( + const [remainingAmount] = spendHelper.consumeLockedStakableAsset( utxoInfo.assetId, utxoInfo.amount, ); diff --git a/src/vms/pvm/etna-builder/spendHelper.test.ts b/src/vms/pvm/etna-builder/spendHelper.test.ts index 512303ae5..2fda02eca 100644 --- a/src/vms/pvm/etna-builder/spendHelper.test.ts +++ b/src/vms/pvm/etna-builder/spendHelper.test.ts @@ -99,11 +99,13 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { }); }); - describe('SpendHelper.shouldConsumeLockedAsset', () => { + describe('SpendHelper.shouldConsumeLockedStakeableAsset', () => { test('returns false for asset not in toStake', () => { const spendHelper = new SpendHelper(DEFAULT_PROPS); - expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(false); + expect(spendHelper.shouldConsumeLockedStakeableAsset('asset')).toBe( + false, + ); }); test('returns false for asset in toStake with 0 value', () => { @@ -112,7 +114,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { toStake: new Map([['asset', 0n]]), }); - expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(false); + expect(spendHelper.shouldConsumeLockedStakeableAsset('asset')).toBe( + false, + ); }); test('returns true for asset in toStake with non-0 value', () => { @@ -121,7 +125,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { toStake: new Map([['asset', 1n]]), }); - expect(spendHelper.shouldConsumeLockedAsset('asset')).toBe(true); + expect(spendHelper.shouldConsumeLockedStakeableAsset('asset')).toBe(true); }); }); @@ -169,7 +173,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { }); }); - describe('SpendHelper.consumeLockedAsset', () => { + describe('SpendHelper.consumeLockedStakeableAsset', () => { const testCases = [ { description: 'consumes the full amount', @@ -216,7 +220,9 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { toStake, }); - expect(spendHelper.consumeLockedAsset(asset, amount)[0]).toBe(expected); + expect(spendHelper.consumeLockedStakableAsset(asset, amount)[0]).toBe( + expected, + ); }, ); @@ -224,7 +230,7 @@ describe('src/vms/pvm/etna-builder/spendHelper', () => { const spendHelper = new SpendHelper(DEFAULT_PROPS); expect(() => { - spendHelper.consumeLockedAsset('asset', -1n); + spendHelper.consumeLockedStakableAsset('asset', -1n); }).toThrow('Amount to consume must be greater than or equal to 0'); }); }); diff --git a/src/vms/pvm/etna-builder/spendHelper.ts b/src/vms/pvm/etna-builder/spendHelper.ts index bc1fa3e45..f464a3024 100644 --- a/src/vms/pvm/etna-builder/spendHelper.ts +++ b/src/vms/pvm/etna-builder/spendHelper.ts @@ -133,12 +133,12 @@ export class SpendHelper { } /** - * Determines if a locked asset should be consumed based on its asset ID. + * Determines if a locked stakeable asset should be consumed based on its asset ID. * * @param {string} assetId - The ID of the asset to check. * @returns {boolean} - Returns true if the asset should be consumed, false otherwise. */ - shouldConsumeLockedAsset(assetId: string): boolean { + shouldConsumeLockedStakeableAsset(assetId: string): boolean { return this.toStake.has(assetId) && this.toStake.get(assetId) !== 0n; } @@ -151,18 +151,18 @@ export class SpendHelper { shouldConsumeAsset(assetId: string): boolean { return ( (this.toBurn.has(assetId) && this.toBurn.get(assetId) !== 0n) || - this.shouldConsumeLockedAsset(assetId) + this.shouldConsumeLockedStakeableAsset(assetId) ); } /** - * Consumes a locked asset based on its asset ID and amount. + * Consumes a locked stakeable asset based on its asset ID and amount. * * @param {string} assetId - The ID of the asset to consume. * @param {bigint} amount - The amount of the asset to consume. * @returns A tuple of the remaining amount in the first position and the amount to stake in the second position. */ - consumeLockedAsset( + consumeLockedStakableAsset( assetId: string, amount: bigint, ): [remainingAmount: bigint, amountToStake: bigint] { @@ -213,7 +213,7 @@ export class SpendHelper { this.toBurn.set(assetId, remainingAmountToBurn - amountToBurn); // Stake any remaining value that should be staked - return this.consumeLockedAsset(assetId, amount - amountToBurn); + return this.consumeLockedStakableAsset(assetId, amount - amountToBurn); } /** From 9e57a3b13e25b1bcfc7cba6ce4662dde1a0a74ce Mon Sep 17 00:00:00 2001 From: Eric Taylor Date: Fri, 27 Sep 2024 10:22:45 -0600 Subject: [PATCH 39/39] refactor: cleanup TODO and magic numbers in tests --- src/vms/pvm/etna-builder/builder.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/vms/pvm/etna-builder/builder.test.ts b/src/vms/pvm/etna-builder/builder.test.ts index 44ec876d0..09828f68b 100644 --- a/src/vms/pvm/etna-builder/builder.test.ts +++ b/src/vms/pvm/etna-builder/builder.test.ts @@ -234,7 +234,12 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { }); test('newImportTx', () => { - const utxos = testUtxos(); + const VALID_AMOUNT = BigInt(50 * 1e9); + const utxos = [ + getLockedUTXO(), + getNotTransferOutput(), + getValidUtxo(new BigIntPr(VALID_AMOUNT)), + ]; const unsignedTx = newImportTx( { @@ -271,8 +276,7 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { [ TransferableOutput.fromNative( testContext.avaxAssetID, - // TODO: How to remove this "magic" number. How do we calculate it correctly from utxos? - 50_000_000_000n - expectedFee, + VALID_AMOUNT - expectedFee, [testAddress1], ), ], @@ -287,10 +291,16 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { }); test('newExportTx', () => { - const utxos = testUtxos(); + const VALID_AMOUNT = BigInt(50 * 1e9); + const OUT_AMOUNT = BigInt(5 * 1e9); + const utxos = [ + getLockedUTXO(), + getNotTransferOutput(), + getValidUtxo(new BigIntPr(VALID_AMOUNT)), + ]; const tnsOut = TransferableOutput.fromNative( testContext.avaxAssetID, - BigInt(5 * 1e9), + OUT_AMOUNT, [toAddress], ); @@ -329,8 +339,7 @@ describe('./src/vms/pvm/etna-builder/builder.test.ts', () => { [ TransferableOutput.fromNative( testContext.avaxAssetID, - // TODO: Remove magic number. How to calculate it correctly from utxos? - 45_000_000_000n - expectedFee, + VALID_AMOUNT - OUT_AMOUNT - expectedFee, fromAddressesBytes, ), ],