diff --git a/pkg/protocol/engine/ledger/ledger/ledger.go b/pkg/protocol/engine/ledger/ledger/ledger.go index 144b10325..afd914167 100644 --- a/pkg/protocol/engine/ledger/ledger/ledger.go +++ b/pkg/protocol/engine/ledger/ledger/ledger.go @@ -545,15 +545,20 @@ func (l *Ledger) processCreatedAndConsumedAccountOutputs(stateDiff mempool.State switch spentOutput.OutputType() { case iotago.OutputAccount: consumedAccount, _ := spentOutput.Output().(*iotago.AccountOutput) + accountID := consumedAccount.AccountID + if accountID == iotago.EmptyAccountID { + accountID = iotago.AccountIDFromOutputID(spentOutput.OutputID()) + } + // if we transition / destroy an account output that doesn't have a block issuer feature or staking, we don't need to track the changes. if consumedAccount.FeatureSet().BlockIssuer() == nil && consumedAccount.FeatureSet().Staking() == nil { return true } - consumedAccounts[consumedAccount.AccountID] = spentOutput + consumedAccounts[accountID] = spentOutput // if we have consumed accounts that are not created in the same slot, we need to track them as destroyed - if _, exists := createdAccounts[consumedAccount.AccountID]; !exists { - destroyedAccounts.Add(consumedAccount.AccountID) + if _, exists := createdAccounts[accountID]; !exists { + destroyedAccounts.Add(accountID) } case iotago.OutputDelegation: diff --git a/pkg/protocol/engine/ledger/ledger/vm.go b/pkg/protocol/engine/ledger/ledger/vm.go index 92942ac33..f86162e61 100644 --- a/pkg/protocol/engine/ledger/ledger/vm.go +++ b/pkg/protocol/engine/ledger/ledger/vm.go @@ -124,6 +124,8 @@ func (v *VM) ValidateSignatures(signedTransaction mempool.SignedTransaction, res } rewardInputSet[delegationID] = reward + default: + return nil, ierrors.Wrapf(iotago.ErrRewardInputInvalid, "reward input cannot point to %s", output.Output().Type()) } } diff --git a/pkg/tests/accounts_test.go b/pkg/tests/accounts_test.go index baf944aa8..bc560555e 100644 --- a/pkg/tests/accounts_test.go +++ b/pkg/tests/accounts_test.go @@ -238,10 +238,9 @@ func Test_StakeDelegateAndDelayedClaim(t *testing.T) { mock.WithBlockIssuerFeature(iotago.BlockIssuerKeys{newAccountBlockIssuerKey}, newAccountExpirySlot), mock.WithStakingFeature(stakedAmount, 421, 0, 10), mock.WithAccountAmount(validatorAccountAmount), + mock.WithAccountMana(mock.MaxBlockManaCost(ts.DefaultWallet().Node.Protocol.CommittedAPI().ProtocolParameters())), ) - genesisCommitment := iotago.NewEmptyCommitment(ts.API) - genesisCommitment.ReferenceManaCost = ts.API.ProtocolParameters().CongestionControlParameters().MinReferenceManaCost ts.SetCurrentSlot(block1Slot) block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1) latestParents := ts.CommitUntilSlot(block1Slot, block1.ID()) diff --git a/pkg/tests/reward_test.go b/pkg/tests/reward_test.go new file mode 100644 index 000000000..36aa76bab --- /dev/null +++ b/pkg/tests/reward_test.go @@ -0,0 +1,259 @@ +package tests + +import ( + "testing" + + "github.com/iotaledger/hive.go/lo" + "github.com/iotaledger/iota-core/pkg/model" + "github.com/iotaledger/iota-core/pkg/protocol/engine/accounts" + "github.com/iotaledger/iota-core/pkg/testsuite" + "github.com/iotaledger/iota-core/pkg/testsuite/mock" + "github.com/iotaledger/iota-core/pkg/utils" + + iotago "github.com/iotaledger/iota.go/v4" + "github.com/iotaledger/iota.go/v4/tpkg" +) + +func setupDelegationTestsuite(t *testing.T) (*testsuite.TestSuite, *mock.Node, *mock.Node) { + ts := testsuite.NewTestSuite(t, + testsuite.WithProtocolParametersOptions( + iotago.WithTimeProviderOptions( + 0, + testsuite.GenesisTimeWithOffsetBySlots(1000, testsuite.DefaultSlotDurationInSeconds), + testsuite.DefaultSlotDurationInSeconds, + 8, + ), + iotago.WithLivenessOptions( + testsuite.DefaultLivenessThresholdLowerBoundInSeconds, + testsuite.DefaultLivenessThresholdUpperBoundInSeconds, + testsuite.DefaultMinCommittableAge, + 100, + 120, + ), + ), + ) + + // Add a validator node to the network. This will add a validator account to the snapshot. + node1 := ts.AddValidatorNode("node1") + // Add a non-validator node to the network. This will not add any accounts to the snapshot. + node2 := ts.AddNode("node2") + // Add a default block issuer to the network. This will add another block issuer account to the snapshot. + wallet := ts.AddDefaultWallet(node1) + + ts.Run(true) + + // Assert validator and block issuer accounts in genesis snapshot. + // Validator node account. + validatorAccountOutput := ts.AccountOutput("Genesis:1") + ts.AssertAccountData(&accounts.AccountData{ + ID: node1.Validator.AccountID, + Credits: accounts.NewBlockIssuanceCredits(iotago.MaxBlockIssuanceCredits/2, 0), + OutputID: validatorAccountOutput.OutputID(), + ExpirySlot: iotago.MaxSlotIndex, + BlockIssuerKeys: node1.Validator.BlockIssuerKeys(), + StakeEndEpoch: iotago.MaxEpochIndex, + ValidatorStake: mock.MinValidatorAccountAmount(ts.API.ProtocolParameters()), + }, ts.Nodes()...) + // Default wallet block issuer account. + blockIssuerAccountOutput := ts.AccountOutput("Genesis:2") + ts.AssertAccountData(&accounts.AccountData{ + ID: wallet.BlockIssuer.AccountID, + Credits: accounts.NewBlockIssuanceCredits(iotago.MaxBlockIssuanceCredits/2, 0), + OutputID: blockIssuerAccountOutput.OutputID(), + ExpirySlot: iotago.MaxSlotIndex, + BlockIssuerKeys: wallet.BlockIssuer.BlockIssuerKeys(), + }, ts.Nodes()...) + + return ts, node1, node2 +} + +// Test that a Delegation Output which delegates to an account which does not exist / did not receive rewards +// can be destroyed. +func Test_Delegation_DestroyOutputWithoutRewards(t *testing.T) { + ts, node1, node2 := setupDelegationTestsuite(t) + defer ts.Shutdown() + + // CREATE DELEGATION TO NEW ACCOUNT FROM BASIC UTXO + accountAddress := tpkg.RandAccountAddress() + var block1Slot iotago.SlotIndex = 1 + ts.SetCurrentSlot(block1Slot) + tx1 := ts.DefaultWallet().CreateDelegationFromInput( + "TX1", + "Genesis:0", + mock.WithDelegatedValidatorAddress(accountAddress), + mock.WithDelegationStartEpoch(1), + ) + block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1) + + latestParents := ts.CommitUntilSlot(block1Slot, block1.ID()) + + block2Slot := ts.CurrentSlot() + tx2 := ts.DefaultWallet().ClaimDelegatorRewards("TX2", "TX1:0") + block2 := ts.IssueBasicBlockWithOptions("block2", ts.DefaultWallet(), tx2, mock.WithStrongParents(latestParents...)) + + ts.CommitUntilSlot(block2Slot, block2.ID()) + + ts.AssertTransactionsExist([]*iotago.Transaction{tx2.Transaction}, true, node1, node2) + ts.AssertTransactionsInCacheAccepted([]*iotago.Transaction{tx2.Transaction}, true, node1, node2) +} + +func Test_Delegation_DelayedClaimingDestroyOutputWithoutRewards(t *testing.T) { + ts, node1, node2 := setupDelegationTestsuite(t) + defer ts.Shutdown() + + // CREATE DELEGATION TO NEW ACCOUNT FROM BASIC UTXO + accountAddress := tpkg.RandAccountAddress() + var block1_2Slot iotago.SlotIndex = 1 + ts.SetCurrentSlot(block1_2Slot) + tx1 := ts.DefaultWallet().CreateDelegationFromInput( + "TX1", + "Genesis:0", + mock.WithDelegatedValidatorAddress(accountAddress), + mock.WithDelegationStartEpoch(1), + ) + block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1) + + // TRANSITION TO DELAYED CLAIMING (IN THE SAME SLOT) + latestCommitment := ts.DefaultWallet().Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment() + apiForSlot := ts.DefaultWallet().Node.Protocol.APIForSlot(block1_2Slot) + + futureBoundedSlotIndex := latestCommitment.Slot() + apiForSlot.ProtocolParameters().MinCommittableAge() + futureBoundedEpochIndex := apiForSlot.TimeProvider().EpochFromSlot(futureBoundedSlotIndex) + + registrationSlot := apiForSlot.TimeProvider().EpochEnd(apiForSlot.TimeProvider().EpochFromSlot(block1_2Slot)) + delegationEndEpoch := futureBoundedEpochIndex + if futureBoundedSlotIndex > registrationSlot { + delegationEndEpoch = futureBoundedEpochIndex + 1 + } + + tx2 := ts.DefaultWallet().DelayedClaimingTransition("TX2", "TX1:0", delegationEndEpoch) + block2 := ts.IssueBasicBlockWithOptions("block2", ts.DefaultWallet(), tx2, mock.WithStrongParents(block1.ID())) + latestParents := ts.CommitUntilSlot(block1_2Slot, block2.ID()) + + // CLAIM ZERO REWARDS + block3Slot := ts.CurrentSlot() + tx3 := ts.DefaultWallet().ClaimDelegatorRewards("TX3", "TX2:0") + block3 := ts.IssueBasicBlockWithOptions("block3", ts.DefaultWallet(), tx3, mock.WithStrongParents(latestParents...)) + + ts.CommitUntilSlot(block3Slot, block3.ID()) + + ts.AssertTransactionsExist([]*iotago.Transaction{tx3.Transaction}, true, node1, node2) + ts.AssertTransactionsInCacheAccepted([]*iotago.Transaction{tx3.Transaction}, true, node1, node2) +} + +// Test that a staking Account which did not earn rewards can remove its staking feature. +func Test_Account_RemoveStakingFeatureWithoutRewards(t *testing.T) { + ts, node1, node2 := setupDelegationTestsuite(t) + defer ts.Shutdown() + + // CREATE NEW ACCOUNT WITH BLOCK ISSUER AND STAKING FEATURES FROM BASIC UTXO + var block1Slot iotago.SlotIndex = 1 + ts.SetCurrentSlot(block1Slot) + + // Set end epoch so the staking feature can be removed as soon as possible. + unbondingPeriod := ts.API.ProtocolParameters().StakingUnbondingPeriod() + startEpoch := ts.API.TimeProvider().EpochFromSlot(block1Slot + ts.API.ProtocolParameters().MaxCommittableAge()) + endEpoch := startEpoch + unbondingPeriod + // The earliest epoch in which we can remove the staking feature and claim rewards. + claimingEpoch := endEpoch + 1 + // Random fixed cost amount. + fixedCost := iotago.Mana(421) + stakedAmount := mock.MinValidatorAccountAmount(ts.API.ProtocolParameters()) + + blockIssuerFeatKey := utils.RandBlockIssuerKey() + // Set the expiry slot beyond the end epoch of the staking feature so we don't have to remove the feature. + blockIssuerFeatExpirySlot := ts.API.TimeProvider().EpochEnd(claimingEpoch) + + tx1 := ts.DefaultWallet().CreateAccountFromInput( + "TX1", + "Genesis:0", + ts.DefaultWallet(), + mock.WithBlockIssuerFeature(iotago.BlockIssuerKeys{blockIssuerFeatKey}, blockIssuerFeatExpirySlot), + mock.WithStakingFeature(stakedAmount, fixedCost, startEpoch, endEpoch), + mock.WithAccountAmount(stakedAmount), + ) + + block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1) + + latestParents := ts.CommitUntilSlot(block1Slot, block1.ID()) + + ts.AssertTransactionsExist([]*iotago.Transaction{tx1.Transaction}, true, node1, node2) + ts.AssertTransactionsInCacheAccepted([]*iotago.Transaction{tx1.Transaction}, true, node1, node2) + + // Commit until the claiming epoch. + latestParents = ts.CommitUntilSlot(ts.API.TimeProvider().EpochStart(claimingEpoch), latestParents...) + + // REMOVE STAKING FEATURE AND CLAIM ZERO REWARDS + block2Slot := ts.CurrentSlot() + tx2 := ts.DefaultWallet().ClaimValidatorRewards("TX2", "TX1:0") + block2 := ts.IssueBasicBlockWithOptions("block2", ts.DefaultWallet(), tx2, mock.WithStrongParents(latestParents...)) + + ts.CommitUntilSlot(block2Slot, block2.ID()) + + ts.AssertTransactionsExist([]*iotago.Transaction{tx2.Transaction}, true, node1) + ts.AssertTransactionsInCacheAccepted([]*iotago.Transaction{tx2.Transaction}, true, node1) + accountOutput := ts.DefaultWallet().Output("TX2:0") + accountID := accountOutput.Output().(*iotago.AccountOutput).AccountID + + ts.AssertAccountData(&accounts.AccountData{ + ID: accountID, + Credits: &accounts.BlockIssuanceCredits{Value: 0, UpdateSlot: block1Slot}, + OutputID: accountOutput.OutputID(), + ExpirySlot: blockIssuerFeatExpirySlot, + BlockIssuerKeys: iotago.BlockIssuerKeys{blockIssuerFeatKey}, + StakeEndEpoch: 0, + ValidatorStake: 0, + }, ts.Nodes()...) + + ts.AssertAccountDiff(accountID, block2Slot, &model.AccountDiff{ + BICChange: -iotago.BlockIssuanceCredits(0), + PreviousUpdatedSlot: 0, + NewExpirySlot: blockIssuerFeatExpirySlot, + PreviousExpirySlot: blockIssuerFeatExpirySlot, + NewOutputID: accountOutput.OutputID(), + PreviousOutputID: ts.DefaultWallet().Output("TX1:0").OutputID(), + BlockIssuerKeysAdded: iotago.NewBlockIssuerKeys(), + BlockIssuerKeysRemoved: iotago.NewBlockIssuerKeys(), + ValidatorStakeChange: -int64(stakedAmount), + StakeEndEpochChange: -int64(endEpoch), + FixedCostChange: -int64(fixedCost), + DelegationStakeChange: 0, + }, false, ts.Nodes()...) +} + +func Test_RewardInputCannotPointToNFTOutput(t *testing.T) { + ts, node1, node2 := setupDelegationTestsuite(t) + defer ts.Shutdown() + + // CREATE NFT FROM BASIC UTXO + var block1Slot iotago.SlotIndex = 1 + ts.SetCurrentSlot(block1Slot) + + tx1 := ts.DefaultWallet().CreateNFTFromInput("TX1", "Genesis:0") + block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1) + + latestParents := ts.CommitUntilSlot(block1Slot, block1.ID()) + + ts.AssertTransactionsExist([]*iotago.Transaction{tx1.Transaction}, true, node1, node2) + ts.AssertTransactionsInCacheAccepted([]*iotago.Transaction{tx1.Transaction}, true, node1, node2) + + // ATTEMPT TO POINT REWARD INPUT TO AN NFT OUTPUT + tx2 := ts.DefaultWallet().TransitionNFTWithTransactionOpts("TX2", "TX1:0", + mock.WithRewardInput( + &iotago.RewardInput{Index: 0}, + 0, + ), + mock.WithCommitmentInput(&iotago.CommitmentInput{ + CommitmentID: ts.DefaultWallet().Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment().MustID(), + })) + + ts.IssueBasicBlockWithOptions("block2", ts.DefaultWallet(), tx2, mock.WithStrongParents(latestParents...)) + + ts.Wait(node1, node2) + + // TODO: Assertions do not pass for node2 because the block does not get forwarded from node1. + // node2 should be added in the assertion when issue iotaledger/iota-core#580 is fixed. + ts.AssertTransactionsExist([]*iotago.Transaction{tx2.Transaction}, true, node1) + signedTx2ID := lo.PanicOnErr(tx2.ID()) + ts.AssertTransactionFailure(signedTx2ID, iotago.ErrRewardInputInvalid, node1) +} diff --git a/pkg/testsuite/accounts.go b/pkg/testsuite/accounts.go index 715276ef4..30faf1a0c 100644 --- a/pkg/testsuite/accounts.go +++ b/pkg/testsuite/accounts.go @@ -11,8 +11,8 @@ import ( ) func (t *TestSuite) AssertAccountData(accountData *accounts.AccountData, nodes ...*mock.Node) { - t.Eventually(func() error { - for _, node := range nodes { + for _, node := range nodes { + t.Eventually(func() error { actualAccountData, exists, err := node.Protocol.MainEngineInstance().Ledger.Account(accountData.ID, node.Protocol.MainEngineInstance().SyncManager.LatestCommitment().Slot()) if err != nil { return ierrors.Wrap(err, "AssertAccountData: failed to load account data") @@ -64,15 +64,15 @@ func (t *TestSuite) AssertAccountData(accountData *accounts.AccountData, nodes . if accountData.LatestSupportedProtocolVersionAndHash != actualAccountData.LatestSupportedProtocolVersionAndHash { return ierrors.Errorf("AssertAccountData: %s: accountID %s expected latest supported protocol version and hash %d, got %d", node.Name, accountData.ID, accountData.LatestSupportedProtocolVersionAndHash, actualAccountData.LatestSupportedProtocolVersionAndHash) } - } - return nil - }) + return nil + }) + } } func (t *TestSuite) AssertAccountDiff(accountID iotago.AccountID, index iotago.SlotIndex, accountDiff *model.AccountDiff, destroyed bool, nodes ...*mock.Node) { - t.Eventually(func() error { - for _, node := range nodes { + for _, node := range nodes { + t.Eventually(func() error { accountsDiffStorage, err := node.Protocol.MainEngineInstance().Storage.AccountDiffs(index) if err != nil { @@ -149,8 +149,8 @@ func (t *TestSuite) AssertAccountDiff(accountID iotago.AccountID, index iotago.S if !assert.Equal(t.fakeTesting, accountDiff.NewLatestSupportedVersionAndHash, actualAccountDiff.NewLatestSupportedVersionAndHash) { return ierrors.Errorf("AssertAccountDiff: %s: expected new latest supported protocol version change %d but actual %d for account %s at slot %d", node.Name, accountDiff.NewLatestSupportedVersionAndHash, actualAccountDiff.NewLatestSupportedVersionAndHash, accountID, index) } - } - return nil - }) + return nil + }) + } } diff --git a/pkg/testsuite/mock/node.go b/pkg/testsuite/mock/node.go index c4a448152..d2c041ec6 100644 --- a/pkg/testsuite/mock/node.go +++ b/pkg/testsuite/mock/node.go @@ -45,6 +45,11 @@ func UnregisterIDAliases() { idAliases = make(map[peer.ID]string) } +type InvalidSignedTransactionEvent struct { + Metadata mempool.SignedTransactionMetadata + Error error +} + type Node struct { Testing *testing.T @@ -69,10 +74,11 @@ type Node struct { candidateEngineActivatedCount atomic.Uint32 mainEngineSwitchedCount atomic.Uint32 - mutex syncutils.RWMutex - attachedBlocks []*blocks.Block - currentSlot iotago.SlotIndex - filteredBlockEvents []*postsolidfilter.BlockFilteredEvent + mutex syncutils.RWMutex + attachedBlocks []*blocks.Block + currentSlot iotago.SlotIndex + filteredBlockEvents []*postsolidfilter.BlockFilteredEvent + invalidTransactionEvents map[iotago.SignedTransactionID]InvalidSignedTransactionEvent } func NewNode(t *testing.T, net *Network, partition string, name string, validator bool) *Node { @@ -107,7 +113,8 @@ func NewNode(t *testing.T, net *Network, partition string, name string, validato Endpoint: net.JoinWithEndpointID(peerID, partition), Workers: workerpool.NewGroup(name), - attachedBlocks: make([]*blocks.Block, 0), + attachedBlocks: make([]*blocks.Block, 0), + invalidTransactionEvents: make(map[iotago.SignedTransactionID]InvalidSignedTransactionEvent), } } @@ -162,6 +169,33 @@ func (n *Node) hookEvents() { n.filteredBlockEvents = append(n.filteredBlockEvents, event) }) + + n.Protocol.MainEngineInstance().Ledger.MemPool().OnSignedTransactionAttached( + func(signedTransactionMetadata mempool.SignedTransactionMetadata) { + signedTxID := signedTransactionMetadata.ID() + + signedTransactionMetadata.OnSignaturesInvalid(func(err error) { + n.mutex.Lock() + defer n.mutex.Unlock() + + n.invalidTransactionEvents[signedTxID] = InvalidSignedTransactionEvent{ + Metadata: signedTransactionMetadata, + Error: err, + } + }) + + transactionMetadata := signedTransactionMetadata.TransactionMetadata() + + transactionMetadata.OnInvalid(func(err error) { + n.mutex.Lock() + defer n.mutex.Unlock() + + n.invalidTransactionEvents[signedTxID] = InvalidSignedTransactionEvent{ + Metadata: signedTransactionMetadata, + Error: err, + } + }) + }) } func (n *Node) hookLogging(failOnBlockFiltered bool) { @@ -419,6 +453,14 @@ func (n *Node) attachEngineLogsWithName(failOnBlockFiltered bool, instance *engi fmt.Printf("%s > [%s] ConflictDAG.ConflictAccepted: %s\n", n.Name, engineName, conflictID) }) + instance.Ledger.MemPool().OnSignedTransactionAttached( + func(signedTransactionMetadata mempool.SignedTransactionMetadata) { + signedTransactionMetadata.OnSignaturesInvalid(func(err error) { + fmt.Printf("%s > [%s] MemPool.SignedTransactionSignaturesInvalid(%s): %s\n", n.Name, engineName, err, signedTransactionMetadata.ID()) + }) + }, + ) + instance.Ledger.OnTransactionAttached(func(transactionMetadata mempool.TransactionMetadata) { fmt.Printf("%s > [%s] Ledger.TransactionAttached: %s\n", n.Name, engineName, transactionMetadata.ID()) @@ -531,6 +573,14 @@ func (n *Node) FilteredBlocks() []*postsolidfilter.BlockFilteredEvent { return n.filteredBlockEvents } +func (n *Node) TransactionFailure(txID iotago.SignedTransactionID) (InvalidSignedTransactionEvent, bool) { + n.mutex.RLock() + defer n.mutex.RUnlock() + event, exists := n.invalidTransactionEvents[txID] + + return event, exists +} + func (n *Node) MainEngineSwitchedCount() int { return int(n.mainEngineSwitchedCount.Load()) } diff --git a/pkg/testsuite/mock/wallet_transactions.go b/pkg/testsuite/mock/wallet_transactions.go index c92069183..36b70e4bd 100644 --- a/pkg/testsuite/mock/wallet_transactions.go +++ b/pkg/testsuite/mock/wallet_transactions.go @@ -20,8 +20,7 @@ import ( func (w *Wallet) CreateAccountFromInput(transactionName string, inputName string, recipientWallet *Wallet, opts ...options.Option[builder.AccountOutputBuilder]) *iotago.SignedTransaction { input := w.Output(inputName) - accountOutput := options.Apply(builder.NewAccountOutputBuilder(recipientWallet.Address(), input.BaseTokenAmount()). - Mana(MaxBlockManaCost(w.Node.Protocol.CommittedAPI().ProtocolParameters())), + accountOutput := options.Apply(builder.NewAccountOutputBuilder(recipientWallet.Address(), input.BaseTokenAmount()), opts).MustBuild() outputStates := iotago.Outputs[iotago.Output]{accountOutput} @@ -424,21 +423,34 @@ func (w *Wallet) ClaimValidatorRewards(transactionName string, inputName string) panic(fmt.Sprintf("failed to calculate reward for output %s: %s", inputName, err)) } + apiForSlot := w.Node.Protocol.APIForSlot(w.currentSlot) + potentialMana := w.PotentialMana(apiForSlot, input) + storedMana := w.StoredMana(apiForSlot, input) + + accountOutput := builder.NewAccountOutputBuilderFromPrevious(inputAccount). + RemoveFeature(iotago.FeatureStaking). + Mana(potentialMana + storedMana + rewardMana). + MustBuild() + signedTransaction := w.createSignedTransactionWithOptions( transactionName, WithAccountInput(input), WithRewardInput( - &iotago.RewardInput{Index: 1}, + &iotago.RewardInput{Index: 0}, rewardMana, ), + WithBlockIssuanceCreditInput(&iotago.BlockIssuanceCreditInput{ + AccountID: accountOutput.AccountID, + }), WithCommitmentInput(&iotago.CommitmentInput{ CommitmentID: w.Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment().MustID(), }), - WithAllotAllManaToAccount(w.currentSlot, inputAccount.AccountID), + WithOutputs(iotago.Outputs[iotago.Output]{accountOutput}), ) return signedTransaction } + func (w *Wallet) AllotManaFromInputs(transactionName string, allotments iotago.Allotments, inputNames ...string) *iotago.SignedTransaction { inputStates := make([]*utxoledger.Output, 0, len(inputNames)) outputStates := make(iotago.Outputs[iotago.Output], 0, len(inputNames)) @@ -488,33 +500,71 @@ func (w *Wallet) ClaimDelegatorRewards(transactionName string, inputName string) panic(fmt.Sprintf("output with alias %s is not *iotago.AccountOutput", inputName)) } + apiForSlot := w.Node.Protocol.APIForSlot(w.currentSlot) + delegationEnd := inputDelegation.EndEpoch + // If Delegation ID is zeroed, the output is in delegating state, which means its End Epoch is not set and we must use the + // "last epoch" for the rewards calculation. + if inputDelegation.DelegationID.Empty() { + futureBoundedSlotIndex := w.currentSlot + apiForSlot.ProtocolParameters().MinCommittableAge() + delegationEnd = apiForSlot.TimeProvider().EpochFromSlot(futureBoundedSlotIndex) - iotago.EpochIndex(1) + } + rewardMana, _, _, err := w.Node.Protocol.MainEngineInstance().SybilProtection.DelegatorReward( inputDelegation.ValidatorAddress.AccountID(), inputDelegation.DelegatedAmount, inputDelegation.StartEpoch, - inputDelegation.EndEpoch, + delegationEnd, ) if err != nil { panic(fmt.Sprintf("failed to calculate reward for output %s: %s", inputName, err)) } + potentialMana := w.PotentialMana(apiForSlot, input) + + // Create Basic Output where the reward will be put. + outputStates := iotago.Outputs[iotago.Output]{&iotago.BasicOutput{ + Amount: input.BaseTokenAmount(), + Mana: rewardMana + potentialMana, + UnlockConditions: iotago.BasicOutputUnlockConditions{ + &iotago.AddressUnlockCondition{Address: w.Address()}, + }, + Features: iotago.BasicOutputFeatures{}, + }} + signedTransaction := w.createSignedTransactionWithOptions( transactionName, - WithInputs(utxoledger.Outputs{input}), WithRewardInput( - &iotago.RewardInput{Index: 1}, + &iotago.RewardInput{Index: 0}, rewardMana, ), WithCommitmentInput(&iotago.CommitmentInput{ CommitmentID: w.Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment().MustID(), }), - WithAllotAllManaToAccount(w.currentSlot, w.BlockIssuer.AccountID), + WithOutputs(outputStates), ) return signedTransaction } +// Computes the Potential Mana that the output generates until the current slot. +func (w *Wallet) PotentialMana(api iotago.API, input *utxoledger.Output) iotago.Mana { + minDeposit := lo.PanicOnErr(api.StorageScoreStructure().MinDeposit(input.Output())) + + if minDeposit > input.BaseTokenAmount() { + return 0 + } + + excessBaseTokens := input.BaseTokenAmount() - minDeposit + + return lo.PanicOnErr(api.ManaDecayProvider().ManaGenerationWithDecay(excessBaseTokens, input.SlotCreated(), w.currentSlot)) +} + +// Computes the decay on stored mana that the output holds until the current slot. +func (w *Wallet) StoredMana(api iotago.API, input *utxoledger.Output) iotago.Mana { + return lo.PanicOnErr(api.ManaDecayProvider().ManaWithDecay(input.StoredMana(), input.SlotCreated(), w.currentSlot)) +} + func (w *Wallet) AllotManaToWallet(transactionName string, inputName string, recipientWallet *Wallet) *iotago.SignedTransaction { input := w.Output(inputName) @@ -527,6 +577,42 @@ func (w *Wallet) AllotManaToWallet(transactionName string, inputName string, rec return signedTransaction } +func (w *Wallet) CreateNFTFromInput(transactionName string, inputName string, opts ...options.Option[builder.NFTOutputBuilder]) *iotago.SignedTransaction { + input := w.Output(inputName) + + nftOutputBuilder := builder.NewNFTOutputBuilder(w.Address(), input.BaseTokenAmount()) + options.Apply(nftOutputBuilder, opts) + nftOutput := nftOutputBuilder.MustBuild() + + return w.createSignedTransactionWithOptions( + transactionName, + WithInputs(utxoledger.Outputs{input}), + WithOutputs(iotago.Outputs[iotago.Output]{nftOutput}), + WithAllotAllManaToAccount(w.currentSlot, w.BlockIssuer.AccountID), + ) +} + +func (w *Wallet) TransitionNFTWithTransactionOpts(transactionName string, inputName string, opts ...options.Option[builder.TransactionBuilder]) *iotago.SignedTransaction { + input, exists := w.outputs[inputName] + if !exists { + panic(fmt.Sprintf("NFT with alias %s does not exist", inputName)) + } + + nftOutput, ok := input.Output().Clone().(*iotago.NFTOutput) + if !ok { + panic(fmt.Sprintf("output with alias %s is not *iotago.NFTOutput", inputName)) + } + + builder.NewNFTOutputBuilderFromPrevious(nftOutput).NFTID(iotago.NFTIDFromOutputID(input.OutputID())).MustBuild() + + return w.createSignedTransactionWithOptions( + transactionName, + append(opts, WithInputs(utxoledger.Outputs{input}), + WithOutputs(iotago.Outputs[iotago.Output]{nftOutput}), + WithAllotAllManaToAccount(w.currentSlot, w.BlockIssuer.AccountID))..., + ) +} + func (w *Wallet) createSignedTransactionWithOptions(transactionName string, opts ...options.Option[builder.TransactionBuilder]) *iotago.SignedTransaction { currentAPI := w.Node.Protocol.CommittedAPI() diff --git a/pkg/testsuite/storage_commitments.go b/pkg/testsuite/storage_commitments.go index 970b14a7a..aed96efb2 100644 --- a/pkg/testsuite/storage_commitments.go +++ b/pkg/testsuite/storage_commitments.go @@ -61,8 +61,8 @@ func (t *TestSuite) AssertEqualStoredCommitmentAtIndex(index iotago.SlotIndex, n func (t *TestSuite) AssertStorageCommitmentBlocks(slot iotago.SlotIndex, expectedBlocksBySlotCommitmentID map[iotago.CommitmentID]iotago.BlockIDs, nodes ...*mock.Node) { mustNodes(nodes) - t.Eventually(func() error { - for _, node := range nodes { + for _, node := range nodes { + t.Eventually(func() error { storedCommitment, err := node.Protocol.MainEngineInstance().Storage.Commitments().Load(slot) if err != nil { return ierrors.Wrapf(err, "AssertStorageCommitmentBlocks: %s: error loading commitment for slot: %d", node.Name, slot) @@ -89,8 +89,8 @@ func (t *TestSuite) AssertStorageCommitmentBlocks(slot iotago.SlotIndex, expecte if !assert.Equal(t.fakeTesting, committedBlocksBySlotCommitmentID, expectedBlocksBySlotCommitmentID) { return ierrors.Errorf("AssertStorageCommitmentBlocks: %s: expected %s, got %s", node.Name, expectedBlocksBySlotCommitmentID, committedBlocksBySlotCommitmentID) } - } - return nil - }) + return nil + }) + } } diff --git a/pkg/testsuite/storage_settings.go b/pkg/testsuite/storage_settings.go index b32f3f069..19e244ed6 100644 --- a/pkg/testsuite/storage_settings.go +++ b/pkg/testsuite/storage_settings.go @@ -81,8 +81,9 @@ func (t *TestSuite) AssertLatestCommitmentSlotIndex(slot iotago.SlotIndex, nodes for _, node := range nodes { t.Eventually(func() error { - if slot != node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Slot() { - return ierrors.Errorf("AssertLatestCommitmentSlotIndex: %s: expected %v, got %v", node.Name, slot, node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Slot()) + latestCommittedSlot := node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Slot() + if slot != latestCommittedSlot { + return ierrors.Errorf("AssertLatestCommitmentSlotIndex: %s: expected %v, got %v", node.Name, slot, latestCommittedSlot) } return nil diff --git a/pkg/testsuite/transactions.go b/pkg/testsuite/transactions.go index 23d2eb885..49ebef170 100644 --- a/pkg/testsuite/transactions.go +++ b/pkg/testsuite/transactions.go @@ -159,3 +159,21 @@ func (t *TestSuite) AssertTransactionInCacheConflicts(transactionConflicts map[* } } } + +func (t *TestSuite) AssertTransactionFailure(signedTxID iotago.SignedTransactionID, txFailureReason error, nodes ...*mock.Node) { + for _, node := range nodes { + t.Eventually(func() error { + + txFailure, exists := node.TransactionFailure(signedTxID) + if !exists { + return ierrors.Errorf("%s: failure for signed transaction %s does not exist", node.Name, signedTxID) + } + + if !ierrors.Is(txFailure.Error, txFailureReason) { + return ierrors.Errorf("%s: expected tx failure reason %s, got %s", node.Name, txFailureReason, txFailure.Error) + } + + return nil + }) + } +}