Skip to content

Commit

Permalink
Merge pull request #603 from iotaledger/stake-delegation-test
Browse files Browse the repository at this point in the history
Add stake delegation test
  • Loading branch information
PhilippGackstatter authored Dec 5, 2023
2 parents ad922ce + 5d0b757 commit aa8e9e2
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 19 deletions.
18 changes: 4 additions & 14 deletions pkg/protocol/engine/ledger/ledger/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,6 @@ func (l *Ledger) processCreatedAndConsumedAccountOutputs(stateDiff mempool.State

createdAccounts[accountID] = createdOutput
case iotago.OutputDelegation:
// The DelegationOutput was created or transitioned => determine later if we need to add the stake to the validator.
delegationOutput, _ := createdOutput.Output().(*iotago.DelegationOutput)
delegationID := delegationOutput.DelegationID
// Check if the output was newly created or if it was transitioned to delayed claiming.
Expand Down Expand Up @@ -563,19 +562,10 @@ func (l *Ledger) processCreatedAndConsumedAccountOutputs(stateDiff mempool.State

case iotago.OutputDelegation:
delegationOutput, _ := spentOutput.Output().(*iotago.DelegationOutput)
delegationID := delegationOutput.DelegationID
if delegationID == iotago.EmptyDelegationID() {
delegationID = iotago.DelegationIDFromOutputID(spentOutput.OutputID())
}

// TODO: do we have a testcase that checks transitioning a delegation output twice in the same slot?
if _, createdDelegationExists := newAccountDelegation[delegationID]; createdDelegationExists {
// the delegation output was created and destroyed in the same slot => do not track the delegation as newly created
delete(newAccountDelegation, delegationID)
} else if delegationOutput.DelegationID.Empty() {
// The Delegation Output was destroyed or transitioned to delayed claiming => subtract the stake from the validator account.
// We check for a non-zero Delegation ID so we don't remove the stake twice when the output was destroyed
// in delayed claiming, since we already subtract it when the output is transitioned.
// The Delegation Output was destroyed or transitioned to delayed claiming.
// Delegation ID zeroed => Output was in delegating state => remove the stake.
// Delegation ID non-zero => Output was in delayed claiming state, hence not delegating => no need to remove the stake.
if delegationOutput.DelegationID.Empty() {
accountDiff := getAccountDiff(accountDiffs, delegationOutput.ValidatorAddress.AccountID())
accountDiff.DelegationStakeChange -= int64(delegationOutput.DelegatedAmount)
}
Expand Down
172 changes: 167 additions & 5 deletions pkg/tests/reward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/iotaledger/iota.go/v4/tpkg"
)

func setupDelegationTestsuite(t *testing.T) (*testsuite.TestSuite, *mock.Node, *mock.Node) {
func setupRewardTestsuite(t *testing.T) (*testsuite.TestSuite, *mock.Node, *mock.Node) {
ts := testsuite.NewTestSuite(t,
testsuite.WithProtocolParametersOptions(
iotago.WithTimeProviderOptions(
Expand Down Expand Up @@ -69,7 +69,7 @@ func setupDelegationTestsuite(t *testing.T) (*testsuite.TestSuite, *mock.Node, *
// 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)
ts, node1, node2 := setupRewardTestsuite(t)
defer ts.Shutdown()

// CREATE DELEGATION TO NEW ACCOUNT FROM BASIC UTXO
Expand Down Expand Up @@ -97,7 +97,7 @@ func Test_Delegation_DestroyOutputWithoutRewards(t *testing.T) {
}

func Test_Delegation_DelayedClaimingDestroyOutputWithoutRewards(t *testing.T) {
ts, node1, node2 := setupDelegationTestsuite(t)
ts, node1, node2 := setupRewardTestsuite(t)
defer ts.Shutdown()

// CREATE DELEGATION TO NEW ACCOUNT FROM BASIC UTXO
Expand Down Expand Up @@ -142,7 +142,7 @@ func Test_Delegation_DelayedClaimingDestroyOutputWithoutRewards(t *testing.T) {

// 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)
ts, node1, node2 := setupRewardTestsuite(t)
defer ts.Shutdown()

// CREATE NEW ACCOUNT WITH BLOCK ISSUER AND STAKING FEATURES FROM BASIC UTXO
Expand Down Expand Up @@ -221,7 +221,7 @@ func Test_Account_RemoveStakingFeatureWithoutRewards(t *testing.T) {
}

func Test_RewardInputCannotPointToNFTOutput(t *testing.T) {
ts, node1, node2 := setupDelegationTestsuite(t)
ts, node1, node2 := setupRewardTestsuite(t)
defer ts.Shutdown()

// CREATE NFT FROM BASIC UTXO
Expand Down Expand Up @@ -256,3 +256,165 @@ func Test_RewardInputCannotPointToNFTOutput(t *testing.T) {
signedTx2ID := lo.PanicOnErr(tx2.ID())
ts.AssertTransactionFailure(signedTx2ID, iotago.ErrRewardInputInvalid, node1)
}

// Test that delegations in all forms are correctly reflected in the staked and delegated amounts.
func Test_Account_StakeAmountCalculation(t *testing.T) {
ts, _, _ := setupRewardTestsuite(t)
defer ts.Shutdown()

// STEP 1: CREATE NEW ACCOUNT WITH A BLOCK ISSUER FEATURE FROM BASIC UTXO.
// This account is not a staker yet.
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 := tpkg.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.WithAccountAmount(stakedAmount),
)

block1 := ts.IssueBasicBlockWithOptions("block1", ts.DefaultWallet(), tx1)
latestParents := ts.CommitUntilSlot(block1Slot, block1.ID())

account := ts.DefaultWallet().Output("TX1:0")
accountID := iotago.AccountIDFromOutputID(account.OutputID())
accountAddress := iotago.AccountAddress(accountID[:])

ts.AssertAccountStake(accountID, 0, 0, ts.Nodes()...)

// STEP 2: CREATE DELEGATION OUTPUT DELEGATING TO THE ACCOUNT.
block2Slot := ts.CurrentSlot()
deleg1 := mock.MinDelegationAmount(ts.API.ProtocolParameters())
tx2 := ts.DefaultWallet().CreateDelegationFromInput(
"TX2",
"TX1:1",
mock.WithDelegationAmount(deleg1),
mock.WithDelegatedAmount(deleg1),
mock.WithDelegatedValidatorAddress(&accountAddress),
mock.WithDelegationStartEpoch(ts.DefaultWallet().DelegationStartFromSlot(block2Slot)),
)

block2 := ts.IssueBasicBlockWithOptions("block2", ts.DefaultWallet(), tx2, mock.WithStrongParents(latestParents...))
latestParents = ts.CommitUntilSlot(block2Slot, block2.ID())

ts.AssertAccountStake(accountID, 0, deleg1, ts.Nodes()...)

// STEP 3: TURN ACCOUNT INTO A REGISTERED VALIDATOR AND CREATE ANOTHER DELEGATION.

block3_4Slot := ts.CurrentSlot()
tx3 := ts.DefaultWallet().TransitionAccount("TX3", "TX1:0",
mock.WithStakingFeature(stakedAmount, fixedCost, startEpoch, endEpoch),
)
block3 := ts.IssueBasicBlockWithOptions("block3", ts.DefaultWallet(), tx3, mock.WithStrongParents(latestParents...))

deleg2 := mock.MinDelegationAmount(ts.API.ProtocolParameters()) + 200
// Create another delegation.
tx4 := ts.DefaultWallet().CreateDelegationFromInput(
"TX4",
"TX2:1",
mock.WithDelegationAmount(deleg2),
mock.WithDelegatedAmount(deleg2),
mock.WithDelegatedValidatorAddress(&accountAddress),
mock.WithDelegationStartEpoch(ts.DefaultWallet().DelegationStartFromSlot(block2Slot)),
)
block4 := ts.IssueBasicBlockWithOptions("block4", ts.DefaultWallet(), tx4, mock.WithStrongParents(block3.ID()))
latestParents = ts.CommitUntilSlot(block3_4Slot, block4.ID())

ts.AssertAccountStake(accountID, stakedAmount, deleg1+deleg2, ts.Nodes()...)

// STEP 4: CREATE A DELEGATION TO THE ACCOUNT AND TRANSITION IT TO DELAYED CLAIMING IN THE SAME SLOT.
block5_6Slot := ts.CurrentSlot()
deleg3 := mock.MinDelegationAmount(ts.API.ProtocolParameters()) + 352
tx5 := ts.DefaultWallet().CreateDelegationFromInput(
"TX5",
"TX4:1",
mock.WithDelegationAmount(deleg3),
mock.WithDelegatedAmount(deleg3),
mock.WithDelegatedValidatorAddress(&accountAddress),
mock.WithDelegationStartEpoch(ts.DefaultWallet().DelegationStartFromSlot(block5_6Slot)),
)
block5 := ts.IssueBasicBlockWithOptions("block5", ts.DefaultWallet(), tx5, mock.WithStrongParents(latestParents...))

tx6 := ts.DefaultWallet().DelayedClaimingTransition("TX6", "TX5:0", ts.DefaultWallet().DelegationEndFromSlot(block5_6Slot))
block6 := ts.IssueBasicBlockWithOptions("block6", ts.DefaultWallet(), tx6, mock.WithStrongParents(block5.ID()))

latestParents = ts.CommitUntilSlot(block5_6Slot, block6.ID())

// Delegated Stake should be unaffected since delayed claiming delegations do not count.
ts.AssertAccountStake(accountID, stakedAmount, deleg1+deleg2, ts.Nodes()...)

// STEP 5: CREATE A DELEGATION TO THE ACCOUNT AND DESTROY IT IN THE SAME SLOT.
block7_8Slot := ts.CurrentSlot()
deleg4 := mock.MinDelegationAmount(ts.API.ProtocolParameters()) + 153
tx7 := ts.DefaultWallet().CreateDelegationFromInput(
"TX7",
"TX5:1",
mock.WithDelegationAmount(deleg4),
mock.WithDelegatedAmount(deleg4),
mock.WithDelegatedValidatorAddress(&accountAddress),
mock.WithDelegationStartEpoch(ts.DefaultWallet().DelegationStartFromSlot(block7_8Slot)),
)
block7 := ts.IssueBasicBlockWithOptions("block7", ts.DefaultWallet(), tx7, mock.WithStrongParents(latestParents...))

tx8 := ts.DefaultWallet().ClaimDelegatorRewards("TX8", "TX7:0")
block8 := ts.IssueBasicBlockWithOptions("block8", ts.DefaultWallet(), tx8, mock.WithStrongParents(block7.ID()))

latestParents = ts.CommitUntilSlot(block7_8Slot, block8.ID())

// Delegated Stake should be unaffected since no new delegation was effectively added in that slot.
ts.AssertAccountStake(accountID, stakedAmount, deleg1+deleg2, ts.Nodes()...)

// STEP 6: REMOVE A DELEGATION BY TRANSITIONING TO DELAYED CLAIMING.
block9Slot := ts.CurrentSlot()
tx9 := ts.DefaultWallet().DelayedClaimingTransition("TX9", "TX4:0", ts.DefaultWallet().DelegationEndFromSlot(block9Slot))
block9 := ts.IssueBasicBlockWithOptions("block9", ts.DefaultWallet(), tx9, mock.WithStrongParents(latestParents...))
// Commit until the claiming epoch so we can remove the staking feature from the account in the next step.
latestParents = ts.CommitUntilSlot(block9Slot, block9.ID())

ts.AssertAccountStake(accountID, stakedAmount, deleg1, ts.Nodes()...)

// STEP 7: DESTROY THE DELEGATION IN DELAYED CLAIMING STATE
// This is to ensure the delegated stake is not subtracted twice from the account.
tx10 := ts.DefaultWallet().ClaimDelegatorRewards("TX19", "TX9:0")
block10 := ts.IssueBasicBlockWithOptions("block10", ts.DefaultWallet(), tx10, mock.WithStrongParents(latestParents...))

// Commit until the claiming epoch so we can remove the staking feature from the account in the next step.
latestParents = ts.CommitUntilSlot(ts.API.TimeProvider().EpochStart(claimingEpoch), block10.ID())

ts.AssertAccountStake(accountID, stakedAmount, deleg1, ts.Nodes()...)

// STEP 8: DESTROY ACCOUNT.
block11Slot := ts.CurrentSlot()
tx11 := ts.DefaultWallet().ClaimValidatorRewards("TX11", "TX3:0")
block11 := ts.IssueBasicBlockWithOptions("block11", ts.DefaultWallet(), tx11, mock.WithStrongParents(latestParents...))
latestParents = ts.CommitUntilSlot(block11Slot, block11.ID())

ts.AssertAccountStake(accountID, 0, deleg1, ts.Nodes()...)

// STEP 9: TRANSITION INITIAL DELEGATION TO DELAYED CLAIMING.
// Ensure that the accounts ledger is correctly updated in this edge case
// where the account to which it points no longer exists.

block12Slot := ts.CurrentSlot()
tx12 := ts.DefaultWallet().DelayedClaimingTransition("TX12", "TX2:0", ts.DefaultWallet().DelegationEndFromSlot(block12Slot))
block12 := ts.IssueBasicBlockWithOptions("block12", ts.DefaultWallet(), tx12, mock.WithStrongParents(latestParents...))
ts.CommitUntilSlot(block12Slot, block12.ID())

ts.AssertAccountStake(accountID, 0, 0, ts.Nodes()...)
}
30 changes: 30 additions & 0 deletions pkg/testsuite/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@ import (
iotago "github.com/iotaledger/iota.go/v4"
)

func (t *TestSuite) AssertAccountStake(accountID iotago.AccountID, validatorStake iotago.BaseToken, delegationStake iotago.BaseToken,
nodes ...*mock.Node) {
for _, node := range nodes {
t.Eventually(func() error {
actualAccountData, exists, err := node.Protocol.MainEngineInstance().Ledger.Account(accountID, node.Protocol.MainEngineInstance().SyncManager.LatestCommitment().Slot())
if err != nil {
return ierrors.Wrap(err, "AssertAccountData: failed to load account data")
}
if !exists {
return ierrors.Errorf("AssertAccountData: %s: account %s does not exist with latest committed slot %d", node.Name, accountID, node.Protocol.MainEngineInstance().SyncManager.LatestCommitment().Slot())
}

if accountID != actualAccountData.ID {
return ierrors.Errorf("AssertAccountData: %s: expected %s, got %s", node.Name, accountID, actualAccountData.ID)
}

if validatorStake != actualAccountData.ValidatorStake {
return ierrors.Errorf("AssertAccountData: %s: accountID %s expected validator stake %d, got %d", node.Name, accountID, validatorStake, actualAccountData.ValidatorStake)
}

if delegationStake != actualAccountData.DelegationStake {
return ierrors.Errorf("AssertAccountData: %s: accountID %s expected delegation stake %d, got %d", node.Name, accountID, delegationStake, actualAccountData.DelegationStake)
}

return nil
},
)
}
}

func (t *TestSuite) AssertAccountData(accountData *accounts.AccountData, nodes ...*mock.Node) {
for _, node := range nodes {
t.Eventually(func() error {
Expand Down
6 changes: 6 additions & 0 deletions pkg/testsuite/mock/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ func MinValidatorAccountAmount(protocolParameters iotago.ProtocolParameters) iot
))
}

func MinDelegationAmount(protocolParameters iotago.ProtocolParameters) iotago.BaseToken {
return lo.PanicOnErr(depositcalculator.MinDeposit(protocolParameters, iotago.OutputDelegation,
depositcalculator.WithAddress(&iotago.Ed25519Address{}),
))
}

// TODO: add the correct formula later.
//
//nolint:revive
Expand Down
40 changes: 40 additions & 0 deletions pkg/testsuite/mock/wallet_transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,46 @@ func (w *Wallet) CreateDelegationFromInput(transactionName string, inputName str
return signedTransaction
}

func (w *Wallet) DelegationStartFromSlot(slot iotago.SlotIndex) iotago.EpochIndex {
latestCommitment := w.Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment()
apiForSlot := w.Node.Protocol.APIForSlot(slot)

pastBoundedSlotIndex := latestCommitment.Slot() + apiForSlot.ProtocolParameters().MaxCommittableAge()
pastBoundedEpochIndex := apiForSlot.TimeProvider().EpochFromSlot(pastBoundedSlotIndex)

registrationSlot := w.registrationSlot(slot)

if pastBoundedSlotIndex <= registrationSlot {
return pastBoundedEpochIndex + 1
}

return pastBoundedEpochIndex + 2
}

func (w *Wallet) DelegationEndFromSlot(slot iotago.SlotIndex) iotago.EpochIndex {
latestCommitment := w.Node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment()
apiForSlot := w.Node.Protocol.APIForSlot(slot)

futureBoundedSlotIndex := latestCommitment.Slot() + apiForSlot.ProtocolParameters().MinCommittableAge()
futureBoundedEpochIndex := apiForSlot.TimeProvider().EpochFromSlot(futureBoundedSlotIndex)

registrationSlot := w.registrationSlot(slot)

if futureBoundedEpochIndex <= iotago.EpochIndex(registrationSlot) {
return futureBoundedEpochIndex
}

return futureBoundedEpochIndex + 1
}

// Returns the registration slot in the epoch X corresponding to the given slot.
// This is the registration slot for epoch X+1.
func (w *Wallet) registrationSlot(slot iotago.SlotIndex) iotago.SlotIndex {
apiForSlot := w.Node.Protocol.APIForSlot(slot)

return apiForSlot.TimeProvider().EpochEnd(apiForSlot.TimeProvider().EpochFromSlot(slot)) - apiForSlot.ProtocolParameters().EpochNearingThreshold()
}

// DelayedClaimingTransition transitions DelegationOutput into delayed claiming state by setting DelegationID and EndEpoch.
func (w *Wallet) DelayedClaimingTransition(transactionName string, inputName string, delegationEndEpoch iotago.EpochIndex) *iotago.SignedTransaction {
input := w.Output(inputName)
Expand Down

0 comments on commit aa8e9e2

Please sign in to comment.