From b1816710a2fc7e194d6c712add5288407a1cff87 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Mon, 12 Aug 2024 13:39:19 -0400 Subject: [PATCH] Refactor P-chain Builder (#3282) --- snow/engine/snowman/engine_test.go | 18 +- utils/slice.go | 19 + wallet/chain/p/builder/builder.go | 564 ++++++++++++++++++------- wallet/chain/p/builder/builder_test.go | 172 ++++++++ 4 files changed, 593 insertions(+), 180 deletions(-) create mode 100644 utils/slice.go create mode 100644 wallet/chain/p/builder/builder_test.go diff --git a/snow/engine/snowman/engine_test.go b/snow/engine/snowman/engine_test.go index 2619dcc727b0..26eccf232ed1 100644 --- a/snow/engine/snowman/engine_test.go +++ b/snow/engine/snowman/engine_test.go @@ -28,6 +28,7 @@ import ( "github.com/ava-labs/avalanchego/snow/engine/snowman/getter" "github.com/ava-labs/avalanchego/snow/snowtest" "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/version" ) @@ -3078,7 +3079,7 @@ func TestShouldIssueBlock(t *testing.T) { chain4Through6 = snowmantest.BuildDescendants(chain0Through3[0], 3) chain7Through10 = snowmantest.BuildDescendants(snowmantest.Genesis, 4) chain11Through11 = snowmantest.BuildDescendants(chain7Through10[1], 1) - blocks = join(chain0Through3, chain4Through6, chain7Through10, chain11Through11) + blocks = utils.Join(chain0Through3, chain4Through6, chain7Through10, chain11Through11) ) require.NoError(t, blocks[0].Accept(context.Background())) @@ -3181,18 +3182,3 @@ func TestShouldIssueBlock(t *testing.T) { }) } } - -// join the provided slices into a single slice. -// -// TODO: Use slices.Concat once the minimum go version is 1.22. -func join[T any](slices ...[]T) []T { - size := 0 - for _, s := range slices { - size += len(s) - } - newSlice := make([]T, 0, size) - for _, s := range slices { - newSlice = append(newSlice, s...) - } - return newSlice -} diff --git a/utils/slice.go b/utils/slice.go new file mode 100644 index 000000000000..c73fda241270 --- /dev/null +++ b/utils/slice.go @@ -0,0 +1,19 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package utils + +// Join merges the provided slices into a single slice. +// +// TODO: Use slices.Concat once the minimum go version is 1.22. +func Join[T any](slices ...[]T) []T { + size := 0 + for _, s := range slices { + size += len(s) + } + newSlice := make([]T, 0, size) + for _, s := range slices { + newSlice = append(newSlice, s...) + } + return newSlice +} diff --git a/wallet/chain/p/builder/builder.go b/wallet/chain/p/builder/builder.go index 97e7388a3a47..ee127ed71c22 100644 --- a/wallet/chain/p/builder/builder.go +++ b/wallet/chain/p/builder/builder.go @@ -15,6 +15,7 @@ import ( "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/verify" "github.com/ava-labs/avalanchego/vms/platformvm/fx" "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" @@ -324,7 +325,13 @@ func (b *builder) NewBaseTx( toStake := map[ids.ID]uint64{} ops := common.NewOptions(options) - inputs, changeOutputs, _, err := b.spend(toBurn, toStake, ops) + inputs, changeOutputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -355,7 +362,13 @@ func (b *builder) NewAddValidatorTx( avaxAssetID: vdr.Wght, } ops := common.NewOptions(options) - inputs, baseOutputs, stakeOutputs, err := b.spend(toBurn, toStake, ops) + inputs, baseOutputs, stakeOutputs, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -385,13 +398,20 @@ func (b *builder) NewAddSubnetValidatorTx( b.context.AVAXAssetID: b.context.StaticFeeConfig.AddSubnetValidatorFee, } toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + subnetAuth, err := b.authorizeSubnet(vdr.Subnet, ops) if err != nil { return nil, err } - subnetAuth, err := b.authorizeSubnet(vdr.Subnet, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -419,13 +439,20 @@ func (b *builder) NewRemoveSubnetValidatorTx( b.context.AVAXAssetID: b.context.StaticFeeConfig.TxFee, } toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + subnetAuth, err := b.authorizeSubnet(subnetID, ops) if err != nil { return nil, err } - subnetAuth, err := b.authorizeSubnet(subnetID, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -458,7 +485,13 @@ func (b *builder) NewAddDelegatorTx( avaxAssetID: vdr.Wght, } ops := common.NewOptions(options) - inputs, baseOutputs, stakeOutputs, err := b.spend(toBurn, toStake, ops) + inputs, baseOutputs, stakeOutputs, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -491,13 +524,20 @@ func (b *builder) NewCreateChainTx( b.context.AVAXAssetID: b.context.StaticFeeConfig.CreateBlockchainTxFee, } toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + subnetAuth, err := b.authorizeSubnet(subnetID, ops) if err != nil { return nil, err } - subnetAuth, err := b.authorizeSubnet(subnetID, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -530,7 +570,13 @@ func (b *builder) NewCreateSubnetTx( } toStake := map[ids.ID]uint64{} ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -558,13 +604,20 @@ func (b *builder) NewTransferSubnetOwnershipTx( b.context.AVAXAssetID: b.context.StaticFeeConfig.TxFee, } toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + subnetAuth, err := b.authorizeSubnet(subnetID, ops) if err != nil { return nil, err } - subnetAuth, err := b.authorizeSubnet(subnetID, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -645,29 +698,12 @@ func (b *builder) NewImportTx( ) } - var ( - inputs []*avax.TransferableInput - outputs = make([]*avax.TransferableOutput, 0, len(importedAmounts)) - importedAVAX = importedAmounts[avaxAssetID] - ) - if importedAVAX > txFee { - importedAmounts[avaxAssetID] -= txFee - } else { - if importedAVAX < txFee { // imported amount goes toward paying tx fee - toBurn := map[ids.ID]uint64{ - avaxAssetID: txFee - importedAVAX, - } - toStake := map[ids.ID]uint64{} - var err error - inputs, outputs, _, err = b.spend(toBurn, toStake, ops) - if err != nil { - return nil, fmt.Errorf("couldn't generate tx inputs/outputs: %w", err) - } + outputs := make([]*avax.TransferableOutput, 0, len(importedAmounts)) + for assetID, amount := range importedAmounts { + if assetID == avaxAssetID { + continue } - delete(importedAmounts, avaxAssetID) - } - for assetID, amount := range importedAmounts { outputs = append(outputs, &avax.TransferableOutput{ Asset: avax.Asset{ID: assetID}, Out: &secp256k1fx.TransferOutput{ @@ -677,6 +713,29 @@ func (b *builder) NewImportTx( }) } + var ( + toBurn = map[ids.ID]uint64{} + toStake = map[ids.ID]uint64{} + excessAVAX uint64 + ) + if importedAVAX := importedAmounts[avaxAssetID]; importedAVAX < txFee { + toBurn[avaxAssetID] = txFee - importedAVAX + } else { + excessAVAX = importedAVAX - txFee + } + + inputs, changeOutputs, _, err := b.spend( + toBurn, + toStake, + excessAVAX, + to, + ops, + ) + if err != nil { + return nil, fmt.Errorf("couldn't generate tx inputs/outputs: %w", err) + } + outputs = append(outputs, changeOutputs...) + avax.SortTransferableOutputs(outputs, txs.Codec) // sort imported outputs tx := &txs.ImportTx{ BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ @@ -711,7 +770,13 @@ func (b *builder) NewExportTx( toStake := map[ids.ID]uint64{} ops := common.NewOptions(options) - inputs, changeOutputs, _, err := b.spend(toBurn, toStake, ops) + inputs, changeOutputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -753,13 +818,20 @@ func (b *builder) NewTransformSubnetTx( assetID: maxSupply - initialSupply, } toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) - inputs, outputs, _, err := b.spend(toBurn, toStake, ops) + subnetAuth, err := b.authorizeSubnet(subnetID, ops) if err != nil { return nil, err } - subnetAuth, err := b.authorizeSubnet(subnetID, ops) + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -811,7 +883,13 @@ func (b *builder) NewAddPermissionlessValidatorTx( assetID: vdr.Wght, } ops := common.NewOptions(options) - inputs, baseOutputs, stakeOutputs, err := b.spend(toBurn, toStake, ops) + inputs, baseOutputs, stakeOutputs, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -854,7 +932,13 @@ func (b *builder) NewAddPermissionlessDelegatorTx( assetID: vdr.Wght, } ops := common.NewOptions(options) - inputs, baseOutputs, stakeOutputs, err := b.spend(toBurn, toStake, ops) + inputs, baseOutputs, stakeOutputs, err := b.spend( + toBurn, + toStake, + 0, + nil, + ops, + ) if err != nil { return nil, err } @@ -926,18 +1010,25 @@ func (b *builder) getBalance( // spend takes in the requested burn amounts and the requested stake amounts. // -// - [amountsToBurn] maps assetID to the amount of the asset to spend without +// - [toBurn] 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. -// - [amountsToStake] maps assetID to the amount of the asset to spend and -// place into 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] maps assetID to the amount of the asset to spend and place into +// 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. +// - [excessAVAX] contains the amount of extra AVAX that spend can produce in +// the change outputs in addition to the consumed and not burned AVAX. +// - [ownerOverride] 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 nil, the default change owner is used. func (b *builder) spend( - amountsToBurn map[ids.ID]uint64, - amountsToStake map[ids.ID]uint64, + toBurn map[ids.ID]uint64, + toStake map[ids.ID]uint64, + excessAVAX uint64, + ownerOverride *secp256k1fx.OutputOwners, options *common.Options, ) ( inputs []*avax.TransferableInput, @@ -961,41 +1052,32 @@ func (b *builder) spend( Threshold: 1, Addrs: []ids.ShortID{addr}, }) + if ownerOverride == nil { + ownerOverride = changeOwner + } - // Initialize the return values with empty slices to preserve backward - // compatibility of the json representation of transactions with no - // inputs or outputs. - inputs = make([]*avax.TransferableInput, 0) - changeOutputs = make([]*avax.TransferableOutput, 0) - stakeOutputs = make([]*avax.TransferableOutput, 0) - - // Iterate over the locked UTXOs - for _, utxo := range utxos { - assetID := utxo.AssetID() - remainingAmountToStake := amountsToStake[assetID] + s := spendHelper{ + toBurn: toBurn, + toStake: toStake, - // If we have staked enough of the asset, then we have no need burn - // more. - if remainingAmountToStake == 0 { - continue - } + // Initialize the return values with empty slices to preserve backward + // compatibility of the json representation of transactions with no + // inputs or outputs. + inputs: make([]*avax.TransferableInput, 0), + changeOutputs: make([]*avax.TransferableOutput, 0), + stakeOutputs: make([]*avax.TransferableOutput, 0), + } - outIntf := utxo.Out - lockedOut, ok := outIntf.(*stakeable.LockOut) - if !ok { - // This output isn't locked, so it will be handled during the next - // iteration of the UTXO set - continue - } - if minIssuanceTime >= lockedOut.Locktime { - // This output isn't locked, so it will be handled during the next - // iteration of the UTXO set + utxosByLocktime := splitByLocktime(utxos, minIssuanceTime) + for _, utxo := range utxosByLocktime.locked { + assetID := utxo.AssetID() + if !s.shouldConsumeLockedAsset(assetID) { continue } - out, ok := lockedOut.TransferableOut.(*secp256k1fx.TransferOutput) - if !ok { - return nil, nil, nil, ErrUnknownOutputType + out, locktime, err := unwrapOutput(utxo.Out) + if err != nil { + return nil, nil, nil, err } inputSigIndices, ok := common.MatchOwners(&out.OutputOwners, addrs, minIssuanceTime) @@ -1004,11 +1086,11 @@ func (b *builder) spend( continue } - inputs = append(inputs, &avax.TransferableInput{ + s.addInput(&avax.TransferableInput{ UTXOID: utxo.UTXOID, Asset: utxo.Asset, In: &stakeable.LockIn{ - Locktime: lockedOut.Locktime, + Locktime: locktime, TransferableIn: &secp256k1fx.TransferInput{ Amt: out.Amt, Input: secp256k1fx.Input{ @@ -1018,46 +1100,42 @@ func (b *builder) spend( }, }) - // Stake any value that should be staked - amountToStake := min( - remainingAmountToStake, // Amount we still need to stake - out.Amt, // Amount available to stake - ) - - // Add the output to the staked outputs - stakeOutputs = append(stakeOutputs, &avax.TransferableOutput{ + excess := s.consumeLockedAsset(assetID, out.Amt) + s.addStakedOutput(&avax.TransferableOutput{ Asset: utxo.Asset, Out: &stakeable.LockOut{ - Locktime: lockedOut.Locktime, + Locktime: locktime, TransferableOut: &secp256k1fx.TransferOutput{ - Amt: amountToStake, + Amt: out.Amt - excess, OutputOwners: out.OutputOwners, }, }, }) - amountsToStake[assetID] -= amountToStake - if remainingAmount := out.Amt - amountToStake; remainingAmount > 0 { - // This input had extra value, so some of it must be returned - changeOutputs = append(changeOutputs, &avax.TransferableOutput{ - Asset: utxo.Asset, - Out: &stakeable.LockOut{ - Locktime: lockedOut.Locktime, - TransferableOut: &secp256k1fx.TransferOutput{ - Amt: remainingAmount, - OutputOwners: out.OutputOwners, - }, - }, - }) + if excess == 0 { + continue } + + // This input had extra value, so some of it must be returned + s.addChangeOutput(&avax.TransferableOutput{ + Asset: utxo.Asset, + Out: &stakeable.LockOut{ + Locktime: locktime, + TransferableOut: &secp256k1fx.TransferOutput{ + Amt: excess, + OutputOwners: out.OutputOwners, + }, + }, + }) } - for assetID, amount := range amountsToStake { + // Add all the remaining stake amounts assuming unlocked UTXOs. + for assetID, amount := range s.toStake { if amount == 0 { continue } - stakeOutputs = append(stakeOutputs, &avax.TransferableOutput{ + s.addStakedOutput(&avax.TransferableOutput{ Asset: avax.Asset{ ID: assetID, }, @@ -1068,31 +1146,61 @@ func (b *builder) spend( }) } - // Iterate over the unlocked UTXOs - for _, utxo := range utxos { + // AVAX is handled last to account for fees. + utxosByAVAXAssetID := splitByAssetID(utxosByLocktime.unlocked, b.context.AVAXAssetID) + for _, utxo := range utxosByAVAXAssetID.other { assetID := utxo.AssetID() - remainingAmountToStake := amountsToStake[assetID] - remainingAmountToBurn := amountsToBurn[assetID] - - // If we have consumed enough of the asset, then we have no need burn - // more. - if remainingAmountToStake == 0 && remainingAmountToBurn == 0 { + if !s.shouldConsumeAsset(assetID) { continue } - outIntf := utxo.Out - if lockedOut, ok := outIntf.(*stakeable.LockOut); ok { - if lockedOut.Locktime > minIssuanceTime { - // This output is currently locked, so this output can't be - // burned. - continue - } - outIntf = lockedOut.TransferableOut + out, _, err := unwrapOutput(utxo.Out) + if err != nil { + return nil, nil, nil, err } - out, ok := outIntf.(*secp256k1fx.TransferOutput) + inputSigIndices, ok := common.MatchOwners(&out.OutputOwners, addrs, minIssuanceTime) if !ok { - return nil, nil, nil, ErrUnknownOutputType + // We couldn't spend this UTXO, so we skip to the next one + continue + } + + s.addInput(&avax.TransferableInput{ + UTXOID: utxo.UTXOID, + Asset: utxo.Asset, + In: &secp256k1fx.TransferInput{ + Amt: out.Amt, + Input: secp256k1fx.Input{ + SigIndices: inputSigIndices, + }, + }, + }) + + excess := s.consumeAsset(assetID, out.Amt) + if excess == 0 { + continue + } + + // This input had extra value, so some of it must be returned + s.addChangeOutput(&avax.TransferableOutput{ + Asset: utxo.Asset, + Out: &secp256k1fx.TransferOutput{ + Amt: excess, + OutputOwners: *changeOwner, + }, + }) + } + + for _, utxo := range utxosByAVAXAssetID.requested { + // If we have consumed enough of the asset, then we have no need burn + // more. + if !s.shouldConsumeAsset(b.context.AVAXAssetID) { + break + } + + out, _, err := unwrapOutput(utxo.Out) + if err != nil { + return nil, nil, nil, err } inputSigIndices, ok := common.MatchOwners(&out.OutputOwners, addrs, minIssuanceTime) @@ -1101,7 +1209,7 @@ func (b *builder) spend( continue } - inputs = append(inputs, &avax.TransferableInput{ + s.addInput(&avax.TransferableInput{ UTXOID: utxo.UTXOID, Asset: utxo.Asset, In: &secp256k1fx.TransferInput{ @@ -1112,57 +1220,38 @@ func (b *builder) spend( }, }) - // Burn any value that should be burned - amountToBurn := min( - remainingAmountToBurn, // Amount we still need to burn - out.Amt, // Amount available to burn - ) - amountsToBurn[assetID] -= amountToBurn - - amountAvailableToStake := out.Amt - amountToBurn - // Burn any value that should be burned - amountToStake := min( - remainingAmountToStake, // Amount we still need to stake - amountAvailableToStake, // Amount available to stake - ) - amountsToStake[assetID] -= amountToStake - if remainingAmount := amountAvailableToStake - amountToStake; remainingAmount > 0 { - // This input had extra value, so some of it must be returned - changeOutputs = append(changeOutputs, &avax.TransferableOutput{ - Asset: utxo.Asset, - Out: &secp256k1fx.TransferOutput{ - Amt: remainingAmount, - OutputOwners: *changeOwner, - }, - }) + excess := s.consumeAsset(b.context.AVAXAssetID, out.Amt) + excessAVAX, err = math.Add(excessAVAX, excess) + if err != nil { + return nil, nil, nil, err } + + // If we need to consume additional AVAX, we should be returning the + // change to the change address. + ownerOverride = changeOwner } - for assetID, amount := range amountsToStake { - if amount != 0 { - return nil, nil, nil, fmt.Errorf( - "%w: provided UTXOs need %d more units of asset %q to stake", - ErrInsufficientFunds, - amount, - assetID, - ) - } + if err := s.verifyAssetsConsumed(); err != nil { + return nil, nil, nil, err } - for assetID, amount := range amountsToBurn { - if amount != 0 { - return nil, nil, nil, fmt.Errorf( - "%w: provided UTXOs need %d more units of asset %q", - ErrInsufficientFunds, - amount, - assetID, - ) + + if excessAVAX > 0 { + newOutput := &avax.TransferableOutput{ + Asset: avax.Asset{ + ID: b.context.AVAXAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: excessAVAX, + OutputOwners: *ownerOverride, + }, } + s.changeOutputs = append(s.changeOutputs, newOutput) } - utils.Sort(inputs) // sort inputs - avax.SortTransferableOutputs(changeOutputs, txs.Codec) // sort the change outputs - avax.SortTransferableOutputs(stakeOutputs, txs.Codec) // sort stake outputs - return inputs, changeOutputs, stakeOutputs, nil + utils.Sort(s.inputs) // sort inputs + avax.SortTransferableOutputs(s.changeOutputs, txs.Codec) // sort the change outputs + avax.SortTransferableOutputs(s.stakeOutputs, txs.Codec) // sort stake outputs + return s.inputs, s.changeOutputs, s.stakeOutputs, nil } func (b *builder) authorizeSubnet(subnetID ids.ID, options *common.Options) (*secp256k1fx.Input, error) { @@ -1200,3 +1289,150 @@ func (b *builder) initCtx(tx txs.UnsignedTx) error { tx.InitCtx(ctx) return nil } + +type spendHelper struct { + toBurn map[ids.ID]uint64 + toStake map[ids.ID]uint64 + + inputs []*avax.TransferableInput + changeOutputs []*avax.TransferableOutput + stakeOutputs []*avax.TransferableOutput +} + +func (s *spendHelper) addInput(input *avax.TransferableInput) { + s.inputs = append(s.inputs, input) +} + +func (s *spendHelper) addChangeOutput(output *avax.TransferableOutput) { + s.changeOutputs = append(s.changeOutputs, output) +} + +func (s *spendHelper) addStakedOutput(output *avax.TransferableOutput) { + s.stakeOutputs = append(s.stakeOutputs, output) +} + +func (s *spendHelper) shouldConsumeLockedAsset(assetID ids.ID) bool { + return s.toStake[assetID] != 0 +} + +func (s *spendHelper) shouldConsumeAsset(assetID ids.ID) bool { + return s.toBurn[assetID] != 0 || s.shouldConsumeLockedAsset(assetID) +} + +func (s *spendHelper) consumeLockedAsset(assetID ids.ID, amount uint64) uint64 { + // Stake any value that should be staked + toStake := min( + s.toStake[assetID], // Amount we still need to stake + amount, // Amount available to stake + ) + s.toStake[assetID] -= toStake + return amount - toStake +} + +func (s *spendHelper) consumeAsset(assetID ids.ID, amount uint64) uint64 { + // Burn any value that should be burned + toBurn := min( + s.toBurn[assetID], // Amount we still need to burn + amount, // Amount available to burn + ) + s.toBurn[assetID] -= toBurn + + // Stake any remaining value that should be staked + return s.consumeLockedAsset(assetID, amount-toBurn) +} + +func (s *spendHelper) verifyAssetsConsumed() error { + for assetID, amount := range s.toStake { + if amount == 0 { + continue + } + + return fmt.Errorf( + "%w: provided UTXOs need %d more units of asset %q to stake", + ErrInsufficientFunds, + amount, + assetID, + ) + } + for assetID, amount := range s.toBurn { + if amount == 0 { + continue + } + + return fmt.Errorf( + "%w: provided UTXOs need %d more units of asset %q", + ErrInsufficientFunds, + amount, + assetID, + ) + } + return nil +} + +type utxosByLocktime struct { + unlocked []*avax.UTXO + locked []*avax.UTXO +} + +// splitByLocktime separates the provided UTXOs into two slices: +// 1. UTXOs that are unlocked with the provided issuance time +// 2. UTXOs that are locked with the provided issuance time +func splitByLocktime(utxos []*avax.UTXO, minIssuanceTime uint64) utxosByLocktime { + split := utxosByLocktime{ + unlocked: make([]*avax.UTXO, 0, len(utxos)), + locked: make([]*avax.UTXO, 0, len(utxos)), + } + for _, utxo := range utxos { + if lockedOut, ok := utxo.Out.(*stakeable.LockOut); ok && minIssuanceTime < lockedOut.Locktime { + split.locked = append(split.locked, utxo) + } else { + split.unlocked = append(split.unlocked, utxo) + } + } + return split +} + +type utxosByAssetID struct { + requested []*avax.UTXO + other []*avax.UTXO +} + +// splitByAssetID separates the provided UTXOs into two slices: +// 1. UTXOs with the provided assetID +// 2. UTXOs with a different assetID +func splitByAssetID(utxos []*avax.UTXO, assetID ids.ID) utxosByAssetID { + split := utxosByAssetID{ + requested: make([]*avax.UTXO, 0, len(utxos)), + other: make([]*avax.UTXO, 0, len(utxos)), + } + for _, utxo := range utxos { + if utxo.AssetID() == assetID { + split.requested = append(split.requested, utxo) + } else { + split.other = append(split.other, utxo) + } + } + return split +} + +// unwrapOutput returns the *secp256k1fx.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 0. +// +// If the output is not a, potentially wrapped, *secp256k1fx.TransferOutput, an +// error is returned. +func unwrapOutput(output verify.State) (*secp256k1fx.TransferOutput, uint64, error) { + var locktime uint64 + if lockedOut, ok := output.(*stakeable.LockOut); ok { + output = lockedOut.TransferableOut + locktime = lockedOut.Locktime + } + + unwrappedOutput, ok := output.(*secp256k1fx.TransferOutput) + if !ok { + return nil, 0, ErrUnknownOutputType + } + return unwrappedOutput, locktime, nil +} diff --git a/wallet/chain/p/builder/builder_test.go b/wallet/chain/p/builder/builder_test.go new file mode 100644 index 000000000000..a6f82af81299 --- /dev/null +++ b/wallet/chain/p/builder/builder_test.go @@ -0,0 +1,172 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package builder + +import ( + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/verify" + "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +func generateUTXOs(random *rand.Rand, assetID ids.ID, locktime uint64) []*avax.UTXO { + utxos := make([]*avax.UTXO, random.Intn(10)) + for i := range utxos { + var output avax.TransferableOut = &secp256k1fx.TransferOutput{ + Amt: random.Uint64(), + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: random.Uint64(), + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + } + if locktime != 0 { + output = &stakeable.LockOut{ + Locktime: locktime, + TransferableOut: output, + } + } + utxos[i] = &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: ids.GenerateTestID(), + OutputIndex: random.Uint32(), + }, + Asset: avax.Asset{ + ID: assetID, + }, + Out: output, + } + } + return utxos +} + +func TestSplitByLocktime(t *testing.T) { + seed := time.Now().UnixNano() + t.Logf("Seed: %d", seed) + random := rand.New(rand.NewSource(seed)) // #nosec G404 + + var ( + require = require.New(t) + + unlockedTime uint64 = 100 + expectedUnlocked = utils.Join( + generateUTXOs(random, ids.GenerateTestID(), 0), + generateUTXOs(random, ids.GenerateTestID(), unlockedTime-1), + generateUTXOs(random, ids.GenerateTestID(), unlockedTime), + ) + expectedLocked = utils.Join( + generateUTXOs(random, ids.GenerateTestID(), unlockedTime+100), + generateUTXOs(random, ids.GenerateTestID(), unlockedTime+1), + ) + utxos = utils.Join( + expectedUnlocked, + expectedLocked, + ) + ) + random.Shuffle(len(utxos), func(i, j int) { + utxos[i], utxos[j] = utxos[j], utxos[i] + }) + + utxosByLocktime := splitByLocktime(utxos, unlockedTime) + require.ElementsMatch(expectedUnlocked, utxosByLocktime.unlocked) + require.ElementsMatch(expectedLocked, utxosByLocktime.locked) +} + +func TestByAssetID(t *testing.T) { + seed := time.Now().UnixNano() + t.Logf("Seed: %d", seed) + random := rand.New(rand.NewSource(seed)) // #nosec G404 + + var ( + require = require.New(t) + + assetID = ids.GenerateTestID() + expectedRequested = generateUTXOs(random, assetID, random.Uint64()) + expectedOther = generateUTXOs(random, ids.GenerateTestID(), random.Uint64()) + utxos = utils.Join( + expectedRequested, + expectedOther, + ) + ) + random.Shuffle(len(utxos), func(i, j int) { + utxos[i], utxos[j] = utxos[j], utxos[i] + }) + + utxosByAssetID := splitByAssetID(utxos, assetID) + require.ElementsMatch(expectedRequested, utxosByAssetID.requested) + require.ElementsMatch(expectedOther, utxosByAssetID.other) +} + +func TestUnwrapOutput(t *testing.T) { + normalOutput := &secp256k1fx.TransferOutput{ + Amt: 123, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 456, + Threshold: 1, + Addrs: []ids.ShortID{ids.ShortEmpty}, + }, + } + + tests := []struct { + name string + output verify.State + expectedOutput *secp256k1fx.TransferOutput + expectedLocktime uint64 + expectedErr error + }{ + { + name: "normal output", + output: normalOutput, + expectedOutput: normalOutput, + expectedLocktime: 0, + expectedErr: nil, + }, + { + name: "locked output", + output: &stakeable.LockOut{ + Locktime: 789, + TransferableOut: normalOutput, + }, + expectedOutput: normalOutput, + expectedLocktime: 789, + expectedErr: nil, + }, + { + name: "locked output with no locktime", + output: &stakeable.LockOut{ + Locktime: 0, + TransferableOut: normalOutput, + }, + expectedOutput: normalOutput, + expectedLocktime: 0, + expectedErr: nil, + }, + { + name: "invalid output", + output: nil, + expectedOutput: nil, + expectedLocktime: 0, + expectedErr: ErrUnknownOutputType, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + output, locktime, err := unwrapOutput(test.output) + require.ErrorIs(err, test.expectedErr) + require.Equal(test.expectedOutput, output) + require.Equal(test.expectedLocktime, locktime) + }) + } +}