From a42795f903f43c8174cd87ce5af0d0808ed1dc51 Mon Sep 17 00:00:00 2001 From: smcio Date: Tue, 13 Oct 2026 10:29:24 +0000 Subject: [PATCH] implement vote program DepositDelegatorRewards and UpdateCommissionBps, and fixed some gaps --- pkg/features/gates.go | 2 +- pkg/replay/vote_rewards_test.go | 107 ++++++ .../slot_451852192_commission_bps.json | 100 ++++++ pkg/sealevel/vote_commission_bps_test.go | 305 ++++++++++++++++++ pkg/sealevel/vote_program.go | 135 +++++++- pkg/sealevel/vote_rewards_test.go | 250 ++++++++++++++ pkg/sealevel/vote_state.go | 47 ++- 7 files changed, 917 insertions(+), 29 deletions(-) create mode 100644 pkg/replay/vote_rewards_test.go create mode 100644 pkg/sealevel/testdata/slot_451852192_commission_bps.json create mode 100644 pkg/sealevel/vote_commission_bps_test.go create mode 100644 pkg/sealevel/vote_rewards_test.go diff --git a/pkg/features/gates.go b/pkg/features/gates.go index 39c8b846..a1ee0ba7 100644 --- a/pkg/features/gates.go +++ b/pkg/features/gates.go @@ -94,7 +94,7 @@ var VoteStateV4 = FeatureGate{Name: "VoteStateV4", Address: base58.MustDecodeFro var BlsPubkeyManagementInVoteAccount = FeatureGate{Name: "BlsPubkeyManagementInVoteAccount", Address: base58.MustDecodeFromString("AnAP9zPV4KL7czAPQbFhpDKV2tx7g4UGNbK9wvXwjaRo")} var CommissionRateInBasisPoints = FeatureGate{Name: "CommissionRateInBasisPoints", Address: base58.MustDecodeFromString("Eg7tXEwMZzS98xaZ1YHUbdRHsaYZiCsSaR6sKgxreoaj")} var CustomCommissionCollector = FeatureGate{Name: "CustomCommissionCollector", Address: base58.MustDecodeFromString("3HcSrCTGXTUnrTueHi4DAwNuMxZSsm5xui2Ax3mgxHqf")} -var BlockRevenueSharing = FeatureGate{Name: "BlockRevenueSharing", Address: base58.MustDecodeFromString("B1ockRevenueSharing111111111111111111111111")} +var BlockRevenueSharing = FeatureGate{Name: "BlockRevenueSharing", Address: base58.MustDecodeFromString("7MYx95UBiJufqnumyN7HfskJ9vKdcGMmhreVguqrE97K")} var VoteAccountInitializeV2 = FeatureGate{Name: "VoteAccountInitializeV2", Address: base58.MustDecodeFromString("VoteAccount1nitia1izeV211111111111111111111")} var RelaxProgramdataAccountCheckMigration = FeatureGate{Name: "RelaxProgramdataAccountCheckMigration", Address: base58.MustDecodeFromString("rexav5eNTUSNT1K2N7cfRjnthwhcP5BC25v2tA4rW4h")} var ReplaceSplTokenWithPToken = FeatureGate{Name: "ReplaceSplTokenWithPToken", Address: base58.MustDecodeFromString("ptokFjwyJtrwCa9Kgo9xoDS59V4QccBGEaRFnRPnSdP")} diff --git a/pkg/replay/vote_rewards_test.go b/pkg/replay/vote_rewards_test.go new file mode 100644 index 00000000..76b9fe77 --- /dev/null +++ b/pkg/replay/vote_rewards_test.go @@ -0,0 +1,107 @@ +package replay + +import ( + "encoding/binary" + "math" + "sync" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// A deposit can fail after its System transfer succeeds. Replay must publish +// only the fee debit in that case, even when the source is also the fee payer. +func TestProcessTransactionDepositDelegatorRewards(t *testing.T) { + for _, tc := range []struct { + name string + pending uint64 + gateDisabled bool + want error + cu uint64 + }{ + {name: "success", pending: 123, cu: 2250}, + {name: "pending_overflow_after_transfer", pending: math.MaxUint64, want: sealevel.InstrErrArithmeticOverflow, cu: 2250}, + {name: "inactive_feature", gateDisabled: true, want: sealevel.InstrErrInvalidInstructionData, cu: 2100}, + } { + t.Run(tc.name, func(t *testing.T) { + slotCtx, cleanup := newCommitTestSlotCtx() + defer cleanup() + slotCtx.VoteTimestampMu = &sync.Mutex{} + slotCtx.VoteTimestamps = make(map[solana.PublicKey]sealevel.BlockTimestamp) + slotCtx.DeferVoteCachePublication = true + for _, gate := range []features.FeatureGate{features.VoteStateV4, features.CommissionRateInBasisPoints, features.CustomCommissionCollector, features.BlockRevenueSharing} { + slotCtx.Features.EnableFeature(gate, 0) + } + if tc.gateDisabled { + slotCtx.Features.DisableFeature(features.BlockRevenueSharing) + } + payer := txfixture.PayerPubkey() + voteKey := solana.PublicKey{0xD7} + state := sealevel.VoteStateVersions{Type: sealevel.VoteStateVersionV4, V4: sealevel.VoteState4{ + NodePubkey: payer, AuthorizedWithdrawer: payer, + InflationRewardsCollector: voteKey, BlockRevenueCollector: voteKey, + PendingDelegatorRewards: tc.pending, + }} + state.V4.AuthorizedVoters.AuthorizedVoters.Set(0, payer) + voteData := make([]byte, sealevel.VoteStateV4Size) + require.NoError(t, sealevel.WriteVersionedVoteStateInPlace(voteData, &state)) + rent := sealevel.NewDefaultRentSysvar() + voteBefore := &accounts.Account{Key: voteKey, Owner: addresses.VoteProgramAddr, Data: voteData, + Lamports: rent.MinimumBalance(uint64(len(voteData))), RentEpoch: math.MaxUint64} + require.NoError(t, slotCtx.SetAccount(voteKey, voteBefore.Clone())) + require.NoError(t, slotCtx.SetAccount(addresses.VoteProgramAddr, &accounts.Account{ + Key: addresses.VoteProgramAddr, Owner: addresses.NativeLoaderAddr, Executable: true, Lamports: 1, RentEpoch: math.MaxUint64, + })) + payerBefore, err := slotCtx.GetAccount(payer) + require.NoError(t, err) + const deposit = uint64(100000) + data := binary.LittleEndian.AppendUint32(nil, 19) + data = binary.LittleEndian.AppendUint64(data, deposit) + tx, err := solana.NewTransaction([]solana.Instruction{solana.NewInstruction(addresses.VoteProgramAddr, + solana.AccountMetaSlice{solana.Meta(voteKey).WRITE(), solana.Meta(payer).WRITE().SIGNER(), solana.Meta(addresses.SystemProgramAddr)}, data)}, + txfixture.TestBlockhash(), solana.TransactionPayer(payer)) + require.NoError(t, err) + privateKey := txfixture.PayerPrivateKey() + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key == payer { + return &privateKey + } + return nil + }) + require.NoError(t, err) + var sigverify sync.WaitGroup + feeInfo, usedCU, processErr := ProcessTransaction(slotCtx, &sigverify, tx, nil, nil, nil, false) + sigverify.Wait() + require.NotNil(t, feeInfo) + require.Equal(t, uint64(5000), feeInfo.TotalFee) + require.Equal(t, tc.cu, usedCU) + payerAfter, err := slotCtx.GetAccount(payer) + require.NoError(t, err) + voteAfter, err := slotCtx.GetAccount(voteKey) + require.NoError(t, err) + if tc.want != nil { + require.ErrorIs(t, processErr, tc.want) + require.Equal(t, voteBefore, voteAfter, "failed transfer effects must not be published") + payerBefore.Lamports -= feeInfo.TotalFee + require.Equal(t, payerBefore, payerAfter, "only the fee debit survives rollback") + require.NotContains(t, slotCtx.ModifiedAccts, voteKey) + require.Empty(t, slotCtx.PendingVoteCache) + } else { + require.NoError(t, processErr) + require.Equal(t, payerBefore.Lamports-feeInfo.TotalFee-deposit, payerAfter.Lamports) + require.Equal(t, voteBefore.Lamports+deposit, voteAfter.Lamports) + state.V4.PendingDelegatorRewards += deposit + require.NoError(t, sealevel.WriteVersionedVoteStateInPlace(voteBefore.Data, &state)) + require.Equal(t, voteBefore.Data, voteAfter.Data) + require.Contains(t, slotCtx.ModifiedAccts, voteKey) + require.Equal(t, tc.pending+deposit, slotCtx.PendingVoteCache[voteKey].V4.PendingDelegatorRewards) + } + }) + } +} diff --git a/pkg/sealevel/testdata/slot_451852192_commission_bps.json b/pkg/sealevel/testdata/slot_451852192_commission_bps.json new file mode 100644 index 00000000..9d8f0c7d --- /dev/null +++ b/pkg/sealevel/testdata/slot_451852192_commission_bps.json @@ -0,0 +1,100 @@ +{ + "hash_evidence": { + "blockhash": "8qposvDUwhueo2ybBocbAK5jGY8h5tXimeCuHPAjpzpj", + "broken_bankhash": "6thaw24e1DVjNqk3QApZNGKcyo6taPf45oTGZJAVG6zS", + "broken_lthash": "6Ofl8Eh/26K58GC+sQrTyWzdjyNXnHalHJFVbhvEMukfC6Vg73FVc+3DhGaxm5HF8VZ+PgqhkRZnCTxCU94vNa791+5WfeQdz1ps4SacnNcMIaUFRSSFn9ndj3a78tWg0ggws9c/h0ZGbYG5I2DtEQkQp88bnQDqIaUE59Np2bXnU3kNqpenTeenT3lx6n9AcRec6WW7bDxdv/iTRnmYFpe6CZjg9pryzer3V6nNqJjAxHNY8Ffv2wq91NcKoJsKd+RsBENga5qA5mDRlTzSsRodsbiPY3elHfRPqGwAnyD1SNamMLPlrsxbtj5wkXuPcgDq5JBPUwvBmKGxn7qGNqcTqK0OviUPBMJfBU2f9Ykzr7c5esFM/uNACATnNCvuyHz+Smmx59O/c+DT2Oqb86y8PLQuXZhwV43xe6H9KCKvCicYfIPi/obgVRyfgAHGWfOX8TjQJUH4OuZ6Yhe2bKyVMyAHieNajb6gr4z9SVkJM1aB3LfY3+XCBAQPb/NpiONl1/35+LfwLKXKTr2XpXokXFH1cCIrpwhP3OoT9HW53C037OXv1lPVTLvHA3g1jtF2Re3Yqoxc19lIyZjGp9GuhJI9MOkZrCskdW8n5RS2D+m3PXwGAludcBoKWy7kYTh3wXt1ofqhTM1RYPSSHLMNLaV3orL+n/mWLLYerfSblK8IUBjd4guVxQIvp6VMa745QxLqbYQqO66tIrMn5K3264bHgxwpx+ms6QJXjGu+QNAA/tp7ANRi7GsHwX8AoY9zGVfKAGVCnwKTYkim0Lqt5HdCft6S/EJOR58cZ/Qzb91YGMUnBvgvqNGXdyXJI9Ny+pLlUEqFElSMk9JmeaE2gwUaWvXaRkXdD7Ex3D35/Qn267vr/lfQ8Z6qYIdFYIwuKi14oP79ynHUUR6KGholUgyLk730V97IQ3Ug8L8DGRtaPTHVHbSG3W5fjL6ATI1rafvJTf848o6RKhlTiPO4ttZ007uJvL55ckN0t/yl+7d7GkeS3ajnyOtjULomK0DUlGUlOSZcUv2Bdr75muLJFUe4CpjGZ1VqKwH9jmmO5DyM5XTouFaRyQzgMk0IS/C+NXUibvmiAtoqIseF6ZCsVb3fvy7nClJ9e3xP7DSrz+hlVl0yjXW1rE7bZCY1gXsB+3u4jM3MQCCkzSl1WW13EO7LRtpQv6hKE7hMOtgXzdLZTONfHpptmIwVm6zrXt8Jt/r5hzSxflyQ+NwljDQ5uswdti6B2JpIBj8HHH6imn1NW0Cuz/74qBOBr2SlBLIGdWqdntKFfgjQ90CVDb8fCwAkEGsumwtoFrR7xNhow3xPM+MbZ1LwjivYW91TDCOcgXeegYpt3nNnyUBy3Y5QP7oJSE00Jx3sothcHZ+ThnBnNalQ343ZYMPDx+kiSW0hEl3j1MrWpHsWO5lpRPtzjoqMb81g6rWMjF0kRiIuIuLRpnI3sRO0N+Od95ojYIeNxYhtc6wLvWVtUj2h+pohKHnP6/avZs8hnEzE2ucV+zCy71PSlNcQdqVB640gWbQhtz3DrLDJBPlgDZ+UlepD+1Mmzf2DAXoWeKDho5p4v149FEYOxeb0xbFY8Y4V1ZjTJkteT7wkMmZ3JIx0A0hzuefKXLqeeEch4eEu4DwLSD66L1aRvsB1QCsjgIFAtou5LpZUxSI2sPlwqEb3wCUjnNuas7h+ovWb67j/nOZNXL0kU/T/toyj7dKboa61M45fmXfCnGhLGNeE5/Ux1C67rRArsPeMR7s8L0BEOcUoun1PfnEksAKhnoN+KxX4+Ad+f8Jb1pOBVvZ79WaGFJayoLLYrTyr2XAYQb0iMMW6+lD/DlgivjyXMzldTdb/GNZAdtL+r+/ReDxgHfO3zIEsd92G9Jx3C6+YM/zuBGRh0ufZEuIeaybk4n9GS1TyISjF4m7z2yHwgL+N5u05sSNETOHZ0JSd01t/qNApHbAMyenbf321fk12cg8ZxWqlfnqloty6FMi8mC3yeP5EAD8A/qbLbmGlwIRXdiKlA0Afna1k98Aox8bageotGABcb2zFnprNCvohuHvsPXAl2RGKLr87zLm0psqJGc5KsiuLpHVVQmuKEmotNydj1HjCJxm7uWFp4IutQn9gtkH7kHXdBGSPKenYVfqbePdl6Nj9mG2mVImn2bdZzoTIHkPyT4pUHD0f8WgueknX4fyoXlhXfFS+gUZioBguBo819gbYrS3JtbnAYrUvFmxPhjCieX1BQX8flvYMJVMX9eOH/UNINiVbEFA906izdxRxcOXtUzT6llWe7UVHLU0qNiLwMVtCu0FDqvWIOd+VTJItlL9uuXDzPj96oPYM310YZa301e5QvPQswzTSl4hd5PN/dvf+RlgIWIkZCVt9KCczLSG+TXnehhQ3DEGaZlTvUMq9rGVwA/4zzYR2z5OwDaSAE7MZbbPNJr9SdNMcYBN11HbpTUDXYYvOJtjNsR+vtewm5lExzYQ0an0Nk8avZKT47K/Kjiii3c2NX3W2ldVCgf3JDAVpDWK9DJVfNtqW/K9h7QxYuvnzfyuTjyLbo8LE8LIzwp1pI2zk6FzBr2dgELPvFK8gxuCZKP1CfjoQ6Ov7pxY1Iizru0EA41Iia/XVgdapOnMuciGEyhrlV55ov5TADrpDoMHaG+9FjjjPBeB1QacZ36OYsOEIAJxGc7xzwGw88SZQSkMXudYGMqatPx7GRHJN65bZhzJcVbmORYQ=", + "broken_lthash_checksum": "F4T1LVCR26NEvrFqHNxtaASMV2eF8A18A8WvxWML8bD", + "broken_vote": { + "Slot": 451567673, + "Key": "66DmdcJqL6nic5Bp2fwJz4ECgwcah6wMDVbcPwwh62it", + "Lamports": 10783634265, + "Data": "AwAAALfcEjXTcrMFkFh4uZHnH6UNhTZfkDBzQsC1xVuOBKrw4Bj4pO/ACBoGwatdyT61LeuhjNWGza16vDJqtqqqRU9LoMb2m4FVVX2wLTjYZTqIVcsdWVI6lurwZPCofMVTKbfcEjXTcrMFkFh4uZHnH6UNhTZfkDBzQsC1xVuOBKrwECcQJwAAAAAAAAAAAacf9leIjKHR5dD5EsD3embSjLmacqvnaH1Vl92209moexCONdu1Tf+O8wrujBCCHwEAAAAAAAAAAPPalBoAAAAAAQAAAAFf0JQaAAAAAAIAAAAAAAAAEQQAAAAAAABdhdUSXFjDj+pZwaXJZ20/brslG5MpWqRFBFh0dIar9hIEAAAAAAAAXYXVElxYw4/qWcGlyWdtP267JRuTKVqkRQRYdHSGq/Y+AAAAAAAAAMIDAAAAAAAANG0IAAAAAAAAAAAAAAAAAMMDAAAAAAAAa/4XAAAAAAA0bQgAAAAAAMQDAAAAAAAAJF48AAAAAABr/hcAAAAAAMUDAAAAAAAASHqfAAAAAAAkXjwAAAAAAMYDAAAAAAAAUtsBAQAAAABIep8AAAAAAMcDAAAAAAAAeoFkAQAAAABS2wEBAAAAAMgDAAAAAAAAIKasAQAAAAB6gWQBAAAAAMkDAAAAAAAABs71AQAAAAAgpqwBAAAAAMoDAAAAAAAAvNBGAgAAAAAGzvUBAAAAAMsDAAAAAAAAOCeMAgAAAAC80EYCAAAAAMwDAAAAAAAA6BvgAgAAAAA4J4wCAAAAAM0DAAAAAAAAQyUxAwAAAADoG+ACAAAAAM4DAAAAAAAAkClmAwAAAABDJTEDAAAAANEDAAAAAAAABI9mAwAAAACQKWYDAAAAANIDAAAAAAAAWrnKAwAAAAAEj2YDAAAAANMDAAAAAAAAwaDtAwAAAABaucoDAAAAANQDAAAAAAAA2egMBAAAAADBoO0DAAAAANUDAAAAAAAAEagrBAAAAADZ6AwEAAAAANYDAAAAAAAA1rRLBAAAAAARqCsEAAAAANcDAAAAAAAAEzVsBAAAAADWtEsEAAAAANgDAAAAAAAAat2KBAAAAAATNWwEAAAAANkDAAAAAAAAveCpBAAAAABq3YoEAAAAANoDAAAAAAAADjbJBAAAAAC94KkEAAAAANsDAAAAAAAAPN7mBAAAAAAONskEAAAAANwDAAAAAAAA0SYHBQAAAAA83uYEAAAAAN0DAAAAAAAA3k4pBQAAAADRJgcFAAAAAN4DAAAAAAAAe2tLBQAAAADeTikFAAAAAN8DAAAAAAAAYXxtBQAAAAB7a0sFAAAAAOADAAAAAAAAuN94BQAAAABhfG0FAAAAAPADAAAAAAAA4fF4BQAAAAC433gFAAAAAPEDAAAAAAAAL0ydBQAAAADh8XgFAAAAAPIDAAAAAAAAQKIEBgAAAAAvTJ0FAAAAAPMDAAAAAAAAwj5sBgAAAABAogQGAAAAAPQDAAAAAAAAu/nTBgAAAADCPmwGAAAAAPUDAAAAAAAA5cM7BwAAAAC7+dMGAAAAAPYDAAAAAAAA642jBwAAAADlwzsHAAAAAPcDAAAAAAAA8GQLCAAAAADrjaMHAAAAAPgDAAAAAAAA2udyCAAAAADwZAsIAAAAAPkDAAAAAAAAsJXaCAAAAADa53IIAAAAAPoDAAAAAAAAw81BCQAAAACwldoIAAAAAPsDAAAAAAAAsFapCQAAAADDzUEJAAAAAPwDAAAAAAAABuMQCgAAAACwVqkJAAAAAP0DAAAAAAAA+H55CgAAAAAG4xAKAAAAAP4DAAAAAAAAj+vhCgAAAAD4fnkKAAAAAP8DAAAAAAAAalYlCwAAAACP6+EKAAAAAAAEAAAAAAAAhNslCwAAAABqViULAAAAAAEEAAAAAAAAccsmCwAAAACE2yULAAAAAAIEAAAAAAAAxaknCwAAAABxyyYLAAAAAAMEAAAAAAAANE0oCwAAAADFqScLAAAAAAQEAAAAAAAAa1UpCwAAAAA0TSgLAAAAAAUEAAAAAAAA4T0qCwAAAABrVSkLAAAAAAYEAAAAAAAAoHgrCwAAAADhPSoLAAAAAAcEAAAAAAAAV6ErCwAAAACgeCsLAAAAAA8EAAAAAAAAD7F2CwAAAABXoSsLAAAAABAEAAAAAAAAOu7dCwAAAAAPsXYLAAAAABEEAAAAAAAAIDdEDAAAAAA67t0LAAAAABIEAAAAAAAAPTFFDAAAAAAgN0QMAAAAAP///////////////////////////////xIEAAAAAAAAO/fXowsAAAA9MUUMAAAAABMEAAAAAAAAgZcvTBMAAAA799ejCwAAABQEAAAAAAAArDAFDiAAAACBly9MEwAAABUEAAAAAAAAFJ0CFyEAAACsMAUOIAAAAPPalBoAAAAAvea5agAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "Owner": [ + 7, + 97, + 72, + 29, + 53, + 116, + 116, + 187, + 124, + 77, + 118, + 36, + 235, + 211, + 189, + 179, + 216, + 53, + 94, + 115, + 209, + 16, + 67, + 252, + 13, + 163, + 83, + 128, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "expected_bankhash": "8fJRV8paZMG9FXQVv9J9FtR4QvW4b8a5o2Y32UVkJuSJ", + "num_signatures": 58, + "parent_bankhash": "CJ4Nmyc8bAzfGEBMeNYA4SKERjupwAoYz2hvRBHdBKRi" + }, + "slot": 451852192, + "transaction": "AtC6qnwBrP7u1OzY8sQcLjUR81nlxaAUBnxfCb9mM58rOchxmzUfOQZZxIFFikWE5l0IaGHF+ruDcamZrJ7SUAoyN3yhKV008G7Kd8Hd1R+rM9ZthVFOKo7Mt9DadSEs4g9SHOuteobWV4my3b9SZddsWDynvA7BJPYfDf5U2KcHAgEBBFzrNVNx62GZnOZ8frbEycTZyxqLMxtWy0R4MY7GxGFk4Bj4pO/ACBoGwatdyT61LeuhjNWGza16vDJqtqqqRU9LoMb2m4FVVX2wLTjYZTqIVcsdWVI6lurwZPCofMVTKQdhSB01dHS7fE12JOvTvbPYNV5z0RBD/A2jU4AAAAAAgU+lQDhEjL+jSFhBOagZ6xrTX6yF+o18RaE+EHZgDeEBAwICAQoSAAAAiBMAAAAA", + "vote_account": { + "Slot": 451567673, + "Key": "66DmdcJqL6nic5Bp2fwJz4ECgwcah6wMDVbcPwwh62it", + "Lamports": 10783634265, + "Data": "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MAUOIAAAAPPalBoAAAAAvea5agAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "Owner": [ + 7, + 97, + 72, + 29, + 53, + 116, + 116, + 187, + 124, + 77, + 118, + 36, + 235, + 211, + 189, + 179, + 216, + 53, + 94, + 115, + 209, + 16, + 67, + 252, + 13, + 163, + 83, + 128, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "source": "Testnet slot 451852192, epoch 1058; signed transaction from finalized RPC and captured shreds; pre-state from persisted parent 451852191; broken post-state and LtHash from read-only replay reproducing the saved mismatch." +} diff --git a/pkg/sealevel/vote_commission_bps_test.go b/pkg/sealevel/vote_commission_bps_test.go new file mode 100644 index 00000000..4fd2dcab --- /dev/null +++ b/pkg/sealevel/vote_commission_bps_test.go @@ -0,0 +1,305 @@ +package sealevel + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + a "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/cu" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/lthash" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +func TestVoteUpdateCommissionBpsSlot451852192(t *testing.T) { + var fixture struct { + Slot uint64 + Transaction string + VoteAccount accounts.Account `json:"vote_account"` + HashEvidence struct { + ParentBankhash string `json:"parent_bankhash"` + Blockhash string `json:"blockhash"` + NumSignatures uint64 `json:"num_signatures"` + BrokenBankhash string `json:"broken_bankhash"` + ExpectedBankhash string `json:"expected_bankhash"` + BrokenLtHashChecksum string `json:"broken_lthash_checksum"` + BrokenLtHash []byte `json:"broken_lthash"` + BrokenVote accounts.Account `json:"broken_vote"` + } `json:"hash_evidence"` + } + data, err := os.ReadFile("testdata/slot_451852192_commission_bps.json") + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &fixture)) + wire, err := base64.StdEncoding.DecodeString(fixture.Transaction) + require.NoError(t, err) + tx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + require.NoError(t, tx.VerifySignatures()) + ft := features.NewFeaturesDefault() + ft.EnableFeature(features.VoteStateV4, 0) + ft.EnableFeature(features.CommissionRateInBasisPoints, 0) + ft.EnableFeature(features.DelayCommissionUpdates, 0) + txAccts := make([]accounts.Account, len(tx.Message.AccountKeys)) + for i, key := range tx.Message.AccountKeys { + txAccts[i] = accounts.Account{Key: key, Owner: a.SystemProgramAddr, Lamports: 1} + if key == fixture.VoteAccount.Key { + txAccts[i] = fixture.VoteAccount + } + if key == a.VoteProgramAddr { + txAccts[i] = accounts.Account{Key: key, Lamports: 1, Owner: a.NativeLoaderAddr, Executable: true} + } + } + before := txAccts[2].Clone() + txCtx := NewTransactionCtx(*NewTransactionAccounts(txAccts), 5, 64) + txCtx.Rent = NewDefaultRentSysvar() + execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault(), Features: *ft} + instr := tx.Message.Instructions[0] + var metas []AccountMeta + for _, idx := range instr.Accounts { + key := tx.Message.AccountKeys[idx] + writable, err := tx.Message.IsWritable(key) + require.NoError(t, err) + metas = append(metas, AccountMeta{Pubkey: key, IsSigner: tx.Message.IsSigner(key), IsWritable: writable}) + } + err = execCtx.ProcessInstruction(instr.Data, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{uint64(instr.ProgramIDIndex)}) + require.NoError(t, err, "testnet accepted this transaction at slot %d", fixture.Slot) + after, err := txCtx.Accounts.GetAccount(2) + require.NoError(t, err) + // The accepted instruction changes only the two-byte inflation commission to 5000 bps. + expected := before.Clone() + binary.LittleEndian.PutUint16(expected.Data[132:134], 5000) + require.Equal(t, expected.Data, after.Data) + require.Equal(t, before.Lamports, after.Lamports) + require.Equal(t, uint64(2100), execCtx.ComputeMeter.Used()) + require.Equal(t, uint16(5000), execCtx.ModifiedVoteStates[before.Key].V4.InflationRewardsCommissionBps) + + // The full-block replay reproduces the crash hash. Replacing just this + // commission in its post-reward account state must recover the certified + // footer hash, proving the missing update explains the entire divergence. + evidence := fixture.HashEvidence + require.Len(t, evidence.BrokenLtHash, 2048) + lt := new(lthash.LtHash).InitWithHash(evidence.BrokenLtHash) + require.Equal(t, evidence.BrokenLtHashChecksum, solana.HashFromBytes(lt.Checksum()).String()) + bankhash := func() string { + h := sha256.New() + parent := solana.MustHashFromBase58(evidence.ParentBankhash) + block := solana.MustHashFromBase58(evidence.Blockhash) + h.Write(parent[:]) + h.Write(binary.LittleEndian.AppendUint64(nil, evidence.NumSignatures)) + h.Write(block[:]) + inner := h.Sum(nil) + h.Reset() + h.Write(inner) + h.Write(lt.Hash()) + return solana.HashFromBytes(h.Sum(nil)).String() + } + require.Equal(t, evidence.BrokenBankhash, bankhash()) + fixedVote := evidence.BrokenVote.Clone() + copy(fixedVote.Data[132:134], after.Data[132:134]) + lt.Sub(new(lthash.LtHash).InitWithAcct(&evidence.BrokenVote)) + lt.Add(new(lthash.LtHash).InitWithAcct(fixedVote)) + require.Equal(t, evidence.ExpectedBankhash, bankhash()) +} + +func TestVoteUpdateCommissionBps(t *testing.T) { + for _, kind := range []uint32{CommissionKindInflationRewards, CommissionKindBlockRevenue} { + t.Run(fmt.Sprintf("kind_%d", kind), func(t *testing.T) { + for _, tc := range []struct { + name string + bps uint16 + mutate func(*features.Features, []accounts.Account, *[]AccountMeta, *[]byte) + want error + }{ + {name: "zero"}, + {name: "fractional_percent", bps: 123}, + {name: "increase", bps: 9000}, + {name: "full_commission", bps: 10000}, + {name: "above_100_percent", bps: 15000}, + {name: "max_u16", bps: 65535}, + {name: "bps_disabled", mutate: func(f *features.Features, _ []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + f.DisableFeature(features.CommissionRateInBasisPoints) + }, want: InstrErrInvalidInstructionData}, + {name: "delay_disabled", mutate: func(f *features.Features, _ []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + f.DisableFeature(features.DelayCommissionUpdates) + }, want: InstrErrInvalidInstructionData}, + {name: "block_revenue_sharing_disabled", mutate: func(f *features.Features, _ []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + f.DisableFeature(features.BlockRevenueSharing) + }, want: func() error { + if kind == CommissionKindBlockRevenue { + return InstrErrInvalidInstructionData + } + return nil + }()}, + {name: "unknown_kind", mutate: func(_ *features.Features, _ []accounts.Account, _ *[]AccountMeta, d *[]byte) { + binary.LittleEndian.PutUint32((*d)[6:], 2) + }, want: InstrErrInvalidInstructionData}, + {name: "truncated_bps", mutate: func(_ *features.Features, _ []accounts.Account, _ *[]AccountMeta, d *[]byte) { + *d = (*d)[:5] + }, want: InstrErrInvalidInstructionData}, + {name: "truncated_kind", mutate: func(_ *features.Features, _ []accounts.Account, _ *[]AccountMeta, d *[]byte) { + *d = (*d)[:9] + }, want: InstrErrInvalidInstructionData}, + {name: "missing_signature", mutate: func(_ *features.Features, _ []accounts.Account, m *[]AccountMeta, _ *[]byte) { + (*m)[1].IsSigner = false + }, want: InstrErrMissingRequiredSignature}, + {name: "wrong_signer", mutate: func(_ *features.Features, _ []accounts.Account, m *[]AccountMeta, _ *[]byte) { + (*m)[1].IsSigner = false + (*m)[0].IsSigner = true + }, want: InstrErrMissingRequiredSignature}, + {name: "missing_authority_account", mutate: func(_ *features.Features, _ []accounts.Account, m *[]AccountMeta, _ *[]byte) { + *m = (*m)[:1] + }, want: InstrErrMissingRequiredSignature}, + {name: "vote_readonly", mutate: func(_ *features.Features, _ []accounts.Account, m *[]AccountMeta, _ *[]byte) { + (*m)[0].IsWritable = false + }, want: InstrErrReadonlyDataModified}, + {name: "wrong_owner", mutate: func(_ *features.Features, a []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + a[1].Owner = a[2].Owner + }, want: InstrErrInvalidAccountOwner}, + {name: "invalid_vote_data", mutate: func(_ *features.Features, a []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + a[1].Data = []byte{99} + }, want: InstrErrInvalidAccountData}, + {name: "retired_v0", mutate: func(_ *features.Features, a []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + a[1].Data = make([]byte, VoteStateV4Size) + }, want: InstrErrInvalidAccountData}, + {name: "v4_without_authorized_voters", mutate: func(_ *features.Features, a []accounts.Account, _ *[]AccountMeta, _ *[]byte) { + v, err := UnmarshalVersionedVoteState(a[1].Data) + require.NoError(t, err) + v.V4.AuthorizedVoters = AuthorizedVoters{} + require.NoError(t, WriteVersionedVoteStateInPlace(a[1].Data, v)) + }}, + {name: "trailing_bytes", mutate: func(_ *features.Features, _ []accounts.Account, _ *[]AccountMeta, d *[]byte) { + *d = append(*d, 1, 2, 3) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + ft := features.NewFeaturesDefault() + for _, gate := range []features.FeatureGate{features.VoteStateV4, features.CommissionRateInBasisPoints, features.DelayCommissionUpdates, features.BlockRevenueSharing} { + ft.EnableFeature(gate, 0) + } + vote, withdrawer := voteCommissionTestPubkey(91), voteCommissionTestPubkey(92) + bls := [48]byte{42} + state := VoteStateVersions{Type: VoteStateVersionV4, V4: VoteState4{ + NodePubkey: voteCommissionTestPubkey(93), AuthorizedWithdrawer: withdrawer, + InflationRewardsCollector: voteCommissionTestPubkey(94), BlockRevenueCollector: voteCommissionTestPubkey(95), + InflationRewardsCommissionBps: 1234, BlockRevenueCommissionBps: 5678, + PendingDelegatorRewards: 999, BlsPubkeyCompressed: &bls, + EpochCredits: []EpochCredits{{Epoch: 1058, Credits: 10000, PrevCredits: 9000}}, + }} + state.V4.AuthorizedVoters.AuthorizedVoters.Set(1058, withdrawer) + data := make([]byte, VoteStateV4Size) + require.NoError(t, WriteVersionedVoteStateInPlace(data, &state)) + data[len(data)-1] = 42 // Preserve bytes outside the serialized state too. + accts := []accounts.Account{ + {Key: a.VoteProgramAddr, Owner: a.NativeLoaderAddr, Lamports: 1, Executable: true}, + {Key: vote, Owner: a.VoteProgramAddr, Lamports: 100000000, Data: data}, + {Key: withdrawer, Owner: a.SystemProgramAddr, Lamports: 1}, + } + metas := []AccountMeta{{Pubkey: vote, IsWritable: true}, {Pubkey: withdrawer, IsSigner: true}} + instr := binary.LittleEndian.AppendUint32(nil, 18) + instr = binary.LittleEndian.AppendUint16(instr, tc.bps) + instr = binary.LittleEndian.AppendUint32(instr, kind) + if tc.mutate != nil { + tc.mutate(ft, accts, &metas, &instr) + } + before := accts[1].Clone() + txCtx := NewTransactionCtx(*NewTransactionAccounts(accts), 5, 64) + txCtx.Rent = NewDefaultRentSysvar() + execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault(), Features: *ft} + err := execCtx.ProcessInstruction(instr, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{0}) + if tc.want != nil { + require.ErrorIs(t, err, tc.want) + require.Equal(t, before.Data, txCtx.Accounts.Accounts[1].Data) + return + } + require.NoError(t, err) + after, getErr := txCtx.Accounts.GetAccount(1) + require.NoError(t, getErr) + expected := before.Clone() + binary.LittleEndian.PutUint16(expected.Data[132+kind*2:], tc.bps) + require.Equal(t, expected.Data, after.Data, "only the selected commission changes") + require.Equal(t, before.Lamports, after.Lamports) + require.Equal(t, uint64(2100), execCtx.ComputeMeter.Used()) + cached, err := marshalVersionedVoteState(execCtx.ModifiedVoteStates[vote]) + require.NoError(t, err) + require.Equal(t, after.Data[:len(cached)], cached, "publish the changed vote state to replay") + }) + } + }) + } +} + +func TestVoteUpdateCommissionBpsMigratesLegacyState(t *testing.T) { + for _, version := range []uint32{VoteStateVersionV1_14_11, VoteStateVersionCurrent} { + for _, kind := range []uint32{CommissionKindInflationRewards, CommissionKindBlockRevenue} { + for _, initialized := range []bool{false, true} { + t.Run(fmt.Sprintf("version_%d_kind_%d_initialized_%t", version, kind, initialized), func(t *testing.T) { + rent := NewDefaultRentSysvar() + vote, withdrawer, node := voteCommissionTestPubkey(81), voteCommissionTestPubkey(82), voteCommissionTestPubkey(83) + current := newVoteStateFromVoteInit(VoteInstrVoteInit{NodePubkey: node, AuthorizedVoter: node, AuthorizedWithdrawer: withdrawer, Commission: 25}, SysvarClock{Epoch: 1058}) + if !initialized { + current.AuthorizedVoters = AuthorizedVoters{} + } + versioned := VoteStateVersions{Type: version} + size := VoteStateV3Size + if version == VoteStateVersionV1_14_11 { + versioned.V1_14_11 = *newVoteState1_14_11FromCurrent(current) + size = VoteStateV2Size + } else { + versioned.Current = *current + } + data := make([]byte, size) + require.NoError(t, WriteVersionedVoteStateInPlace(data, &versioned)) + accts := []accounts.Account{ + {Key: a.VoteProgramAddr, Owner: a.NativeLoaderAddr, Lamports: 1, Executable: true}, + {Key: vote, Owner: a.VoteProgramAddr, Lamports: rent.MinimumBalance(VoteStateV4Size), Data: data}, + {Key: withdrawer, Owner: a.SystemProgramAddr, Lamports: 1}, + } + metas := []AccountMeta{{Pubkey: vote, IsWritable: true}, {Pubkey: withdrawer, IsSigner: true}} + txCtx := NewTransactionCtx(*NewTransactionAccounts(accts), 5, 64) + txCtx.Rent = rent + ft := features.NewFeaturesDefault() + for _, gate := range []features.FeatureGate{features.VoteStateV4, features.CommissionRateInBasisPoints, features.DelayCommissionUpdates, features.BlockRevenueSharing} { + ft.EnableFeature(gate, 0) + } + execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault(), Features: *ft} + instr := binary.LittleEndian.AppendUint32(nil, 18) + instr = binary.LittleEndian.AppendUint16(instr, 5678) + instr = binary.LittleEndian.AppendUint32(instr, kind) + err := execCtx.ProcessInstruction(instr, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{0}) + after, getErr := txCtx.Accounts.GetAccount(1) + require.NoError(t, getErr) + if !initialized { + require.ErrorIs(t, err, InstrErrUninitializedAccount) + require.Equal(t, data, after.Data) + return + } + require.NoError(t, err) + require.Len(t, after.Data, VoteStateV4Size) + result, err := UnmarshalVersionedVoteState(after.Data) + require.NoError(t, err) + require.Equal(t, uint32(VoteStateVersionV4), result.Type) + require.Equal(t, node, result.V4.NodePubkey) + require.Equal(t, withdrawer, result.V4.AuthorizedWithdrawer) + require.Equal(t, vote, result.V4.InflationRewardsCollector) + require.Equal(t, node, result.V4.BlockRevenueCollector) + require.Zero(t, result.V4.PendingDelegatorRewards) + require.Nil(t, result.V4.BlsPubkeyCompressed) + if kind == CommissionKindInflationRewards { + require.Equal(t, uint16(5678), result.V4.InflationRewardsCommissionBps) + require.Equal(t, uint16(10000), result.V4.BlockRevenueCommissionBps) + } else { + require.Equal(t, uint16(2500), result.V4.InflationRewardsCommissionBps) + require.Equal(t, uint16(5678), result.V4.BlockRevenueCommissionBps) + } + }) + } + } + } +} diff --git a/pkg/sealevel/vote_program.go b/pkg/sealevel/vote_program.go index 72e16f66..3950c49a 100644 --- a/pkg/sealevel/vote_program.go +++ b/pkg/sealevel/vote_program.go @@ -37,6 +37,8 @@ const ( VoteProgramInstrTypeTowerSyncSwitch VoteProgramInstrTypeInitializeAccountV2 = 16 VoteProgramInstrTypeUpdateCommissionCollector = 17 + VoteProgramInstrTypeUpdateCommissionBps = 18 + VoteProgramInstrTypeDepositDelegatorRewards = 19 ) const ( @@ -1143,6 +1145,40 @@ func VoteProgramExecute(execCtx *ExecutionCtx) error { err = VoteProgramUpdateCommissionCollector(execCtx, instrCtx, kind, signers, rent) } + case VoteProgramInstrTypeUpdateCommissionBps: + { + commissionBps, decodeErr := decoder.ReadUint16(bin.LE) + if decodeErr != nil { + return InstrErrInvalidInstructionData + } + kind, decodeErr := decoder.ReadUint32(bin.LE) + if decodeErr != nil || kind > CommissionKindBlockRevenue { + return InstrErrInvalidInstructionData + } + if !execCtx.Features.IsActive(features.CommissionRateInBasisPoints) || + !execCtx.Features.IsActive(features.DelayCommissionUpdates) { + return InstrErrInvalidInstructionData + } + err = VoteProgramUpdateCommissionBps(execCtx, me, commissionBps, kind, signers) + } + + case VoteProgramInstrTypeDepositDelegatorRewards: + { + deposit, decodeErr := decoder.ReadUint64(bin.LE) + if decodeErr != nil { + return InstrErrInvalidInstructionData + } + if !execCtx.Features.IsActive(features.CommissionRateInBasisPoints) || + !execCtx.Features.IsActive(features.CustomCommissionCollector) || + !execCtx.Features.IsActive(features.BlockRevenueSharing) { + return InstrErrInvalidInstructionData + } + if err = instrCtx.CheckNumOfInstructionAccounts(2); err != nil { + return err + } + err = VoteProgramDepositDelegatorRewards(execCtx, instrCtx, deposit, signers) + } + default: // invalid instruction { err = InstrErrInvalidInstructionData @@ -1518,6 +1554,93 @@ func VoteProgramUpdateCommission(execCtx *ExecutionCtx, voteAcct *BorrowedAccoun return err } +// VoteProgramUpdateCommissionBps follows Agave's vote_state::update_commission_bps +// (SIMD-0291). Unlike the legacy percentage instruction, it has no epoch timing +// restriction and accepts the full u16 range, including values above 10,000 bps. +func VoteProgramUpdateCommissionBps(execCtx *ExecutionCtx, voteAcct *BorrowedAccount, commissionBps uint16, kind uint32, signers []solana.PublicKey) error { + if kind > CommissionKindBlockRevenue || + (kind == CommissionKindBlockRevenue && !execCtx.Features.IsActive(features.BlockRevenueSharing)) { + return InstrErrInvalidInstructionData + } + + // Match the checked V4 decoder: retired V0_23_5 is invalid, legacy states + // require an authorized voter, and every successfully decoded V4 is initialized. + if data := voteAcct.Data(); len(data) >= 4 && bin.LE.Uint32(data[:4]) == VoteStateVersionV0_23_5 { + return InstrErrInvalidAccountData + } + versioned, err := UnmarshalVersionedVoteState(voteAcct.Data()) + if err != nil { + return err + } + if versioned.Type != VoteStateVersionV4 && !versioned.IsInitialized() { + return InstrErrUninitializedAccount + } + voteState := versioned.ConvertToCurrent() + if err := verifySigner(voteState.AuthorizedWithdrawer, signers); err != nil { + return err + } + + // Apply legacy-to-V4 defaults before setting the requested commission, so + // serialization preserves both the new basis points and the other V4 fields. + v4 := newVoteState4FromCurrent(voteState, voteAcct.Key()) + if kind == CommissionKindInflationRewards { + v4.InflationRewardsCommissionBps = commissionBps + } else { + v4.BlockRevenueCommissionBps = commissionBps + } + updated := &VoteStateVersions{Type: VoteStateVersionV4, V4: *v4} + return setVoteAccountState(execCtx, voteAcct, updated.ConvertToCurrent(), execCtx.Features) +} + +// VoteProgramDepositDelegatorRewards follows Agave v4.4.0-beta.0's SIMD-0123 +// path: require an existing V4 state before System Program CPI, then update +// pending rewards. In particular, even a zero deposit must execute the CPI, +// and pending-reward overflow is checked after the transfer (and its CU cost). +func VoteProgramDepositDelegatorRewards(execCtx *ExecutionCtx, instrCtx *InstructionCtx, deposit uint64, signers []solana.PublicKey) error { + txCtx := execCtx.TransactionContext + voteKey, err := instrCtx.KeyOfInstructionAccount(txCtx, 0) + if err != nil { + return err + } + sourceKey, err := instrCtx.KeyOfInstructionAccount(txCtx, 1) + if err != nil { + return err + } + if err = verifySigner(sourceKey, signers); err != nil { + return err + } + + voteAcct, err := instrCtx.BorrowInstructionAccount(txCtx, 0) + if err != nil { + return err + } + versioned, err := UnmarshalVersionedVoteState(voteAcct.Data()) + voteAcct.Drop() + if err != nil { + return err + } + // Unlike other V4 operations, a deposit must not migrate a legacy state. + // Every successfully decoded V4 is initialized, even with no voters. + if versioned.Type != VoteStateVersionV4 { + return InstrErrInvalidAccountData + } + + if err = execCtx.NativeInvoke(*newTransferInstruction(sourceKey, voteKey, deposit), nil); err != nil { + return err + } + + voteAcct, err = instrCtx.BorrowInstructionAccount(txCtx, 0) + if err != nil { + return err + } + defer voteAcct.Drop() + versioned.V4.PendingDelegatorRewards, err = safemath.CheckedAddU64(versioned.V4.PendingDelegatorRewards, deposit) + if err != nil { + return InstrErrArithmeticOverflow + } + return setVoteAccountStateV4(execCtx, voteAcct, &versioned.V4, execCtx.Features) +} + func verifyAndGetVoteState(voteAcct *BorrowedAccount, clock SysvarClock, signers []solana.PublicKey, f features.Features) (*VoteState, error) { versioned, err := UnmarshalVersionedVoteState(voteAcct.Data()) if err != nil { @@ -2087,6 +2210,12 @@ func VoteProgramWithdraw(execCtx *ExecutionCtx, txCtx *TransactionCtx, instrCtx } if remainingBalance == 0 { + // Pending delegator rewards are reserved funds, not withdrawer funds. + // Agave applies this check independently of the feature gate; legacy + // states and accounts predating SIMD-0123 have a zero pending balance. + if voteState.v4PendingDelegatorRewards != 0 { + return InstrErrInsufficientFunds + } var rejectActiveVoteAcctClose bool if len(voteState.EpochCredits) != 0 { lastEpochWithCredits := voteState.EpochCredits[len(voteState.EpochCredits)-1].Epoch @@ -2109,7 +2238,11 @@ func VoteProgramWithdraw(execCtx *ExecutionCtx, txCtx *TransactionCtx, instrCtx } } else { minRentExemptBalance := rent.MinimumBalance(uint64(len(voteAcct.Data()))) - if remainingBalance < minRentExemptBalance { + minBalance, err := safemath.CheckedAddU64(minRentExemptBalance, voteState.v4PendingDelegatorRewards) + if err != nil { + return InstrErrArithmeticOverflow + } + if remainingBalance < minBalance { return InstrErrInsufficientFunds } } diff --git a/pkg/sealevel/vote_rewards_test.go b/pkg/sealevel/vote_rewards_test.go new file mode 100644 index 00000000..c7221c4a --- /dev/null +++ b/pkg/sealevel/vote_rewards_test.go @@ -0,0 +1,250 @@ +package sealevel + +import ( + "encoding/binary" + "math" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + a "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/base58" + "github.com/Overclock-Validator/mithril/pkg/cu" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +func newVoteRewardsTestContext(t *testing.T, pending uint64) (*ExecutionCtx, []AccountMeta) { + t.Helper() + vote, source, withdrawer := voteCommissionTestPubkey(101), voteCommissionTestPubkey(102), voteCommissionTestPubkey(103) + bls := [48]byte{42} + state := VoteStateVersions{Type: VoteStateVersionV4, V4: VoteState4{ + NodePubkey: voteCommissionTestPubkey(104), AuthorizedWithdrawer: withdrawer, + InflationRewardsCollector: voteCommissionTestPubkey(105), BlockRevenueCollector: voteCommissionTestPubkey(106), + InflationRewardsCommissionBps: 1234, BlockRevenueCommissionBps: 5678, + PendingDelegatorRewards: pending, BlsPubkeyCompressed: &bls, + EpochCredits: []EpochCredits{{Epoch: 1055, Credits: 10000, PrevCredits: 9000}}, + }} + state.V4.AuthorizedVoters.AuthorizedVoters.Set(1058, withdrawer) + data := make([]byte, VoteStateV4Size) + require.NoError(t, WriteVersionedVoteStateInPlace(data, &state)) + data[len(data)-1] = 42 + rent := NewDefaultRentSysvar() + accts := []accounts.Account{ + {Key: a.VoteProgramAddr, Owner: a.NativeLoaderAddr, Executable: true, Lamports: 1}, + {Key: vote, Owner: a.VoteProgramAddr, Data: data, Lamports: rent.MinimumBalance(VoteStateV4Size) + 1000000}, + {Key: source, Owner: a.SystemProgramAddr, Lamports: 10000000}, + {Key: a.SystemProgramAddr, Owner: a.NativeLoaderAddr, Executable: true, Lamports: 1}, + {Key: withdrawer, Owner: a.SystemProgramAddr, Lamports: 1}, + } + txCtx := NewTransactionCtx(*NewTransactionAccounts(accts), 5, 64) + txCtx.Rent = rent + ft := features.NewFeaturesDefault() + for _, gate := range []features.FeatureGate{features.VoteStateV4, features.CommissionRateInBasisPoints, features.CustomCommissionCollector, features.BlockRevenueSharing} { + ft.EnableFeature(gate, 0) + } + execCtx := &ExecutionCtx{TransactionContext: txCtx, Features: *ft, ComputeMeter: cu.NewComputeMeterDefault(), RecordInnerInstructions: true} + metas := []AccountMeta{{Pubkey: vote, IsWritable: true}, {Pubkey: source, IsWritable: true, IsSigner: true}, {Pubkey: a.SystemProgramAddr}} + return execCtx, metas +} + +// Agave v4.4.0-beta.0 vote_processor.rs's deposit tests exercise real System +// CPI, including its errors and 150 CU cost. Do not replace it with a balance +// adjustment or return early for a zero deposit. +func TestVoteDepositDelegatorRewards(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*ExecutionCtx, *[]AccountMeta, *[]byte) + want error + cpi bool + }{ + {name: "success", cpi: true}, + {name: "zero", mutate: func(_ *ExecutionCtx, _ *[]AccountMeta, d *[]byte) { binary.LittleEndian.PutUint64((*d)[4:], 0) }, cpi: true}, + {name: "trailing_bytes", mutate: func(_ *ExecutionCtx, _ *[]AccountMeta, d *[]byte) { *d = append(*d, 1, 2, 3) }, cpi: true}, + {name: "preserve_v4_without_migration_gate", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.Features.DisableFeature(features.VoteStateV4) + }, cpi: true}, + {name: "resize_v4", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[1].Data = e.TransactionContext.Accounts.Accounts[1].Data[:1000] + }, cpi: true}, + {name: "resize_not_rent_exempt_after_cpi", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + account := e.TransactionContext.Accounts.Accounts[1] + account.Data = account.Data[:1000] + account.Lamports = 1 + }, want: InstrErrAccountNotRentExempt, cpi: true}, + {name: "bps_disabled", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.Features.DisableFeature(features.CommissionRateInBasisPoints) + }, want: InstrErrInvalidInstructionData}, + {name: "collector_disabled", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.Features.DisableFeature(features.CustomCommissionCollector) + }, want: InstrErrInvalidInstructionData}, + {name: "revenue_disabled", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.Features.DisableFeature(features.BlockRevenueSharing) + }, want: InstrErrInvalidInstructionData}, + {name: "old_feature_address", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.Features.DisableFeature(features.BlockRevenueSharing) + e.Features.EnableFeature(features.FeatureGate{Name: "BlockRevenueSharing", Address: base58.MustDecodeFromString("B1ockRevenueSharing111111111111111111111111")}, 0) + }, want: InstrErrInvalidInstructionData}, + {name: "truncated_deposit", mutate: func(_ *ExecutionCtx, _ *[]AccountMeta, d *[]byte) { *d = (*d)[:11] }, want: InstrErrInvalidInstructionData}, + {name: "missing_source", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { *m = (*m)[:1] }, want: InstrErrMissingAccount}, + {name: "missing_system_program", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { *m = (*m)[:2] }, want: InstrErrMissingAccount}, + {name: "unsigned_source", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { (*m)[1].IsSigner = false }, want: InstrErrMissingRequiredSignature}, + {name: "invalid_state", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[1].Data = []byte{99} + }, want: InstrErrInvalidAccountData}, + {name: "zeroed_state", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + clear(e.TransactionContext.Accounts.Accounts[1].Data) + }, want: InstrErrInvalidAccountData}, + {name: "v3_not_migrated", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + account := e.TransactionContext.Accounts.Accounts[1] + v, err := UnmarshalVersionedVoteState(account.Data) + require.NoError(t, err) + require.NoError(t, WriteVersionedVoteStateInPlace(account.Data, &VoteStateVersions{Type: VoteStateVersionCurrent, Current: *v.ConvertToCurrent()})) + }, want: InstrErrInvalidAccountData}, + {name: "v4_without_voters", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + account := e.TransactionContext.Accounts.Accounts[1] + v, err := UnmarshalVersionedVoteState(account.Data) + require.NoError(t, err) + v.V4.AuthorizedVoters = AuthorizedVoters{} + require.NoError(t, WriteVersionedVoteStateInPlace(account.Data, v)) + }, cpi: true}, + {name: "source_owner", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[2].Owner = a.VoteProgramAddr + }, want: InstrErrExternalAccountLamportSpend, cpi: true}, + {name: "source_data", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[2].Data = []byte{1} + }, want: InstrErrInvalidArgument, cpi: true}, + {name: "source_equals_vote", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { + (*m)[1].Pubkey = (*m)[0].Pubkey + (*m)[0].IsSigner = true // Duplicate accounts share message privileges. + }, want: InstrErrInvalidArgument, cpi: true}, + {name: "insufficient_lamports", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[2].Lamports = 99999 + }, want: SystemProgErrResultWithNegativeLamports, cpi: true}, + {name: "source_readonly", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { (*m)[1].IsWritable = false }, want: InstrErrPrivilegeEscalation}, + {name: "vote_readonly", mutate: func(_ *ExecutionCtx, m *[]AccountMeta, _ *[]byte) { (*m)[0].IsWritable = false }, want: InstrErrPrivilegeEscalation}, + {name: "pending_overflow_after_cpi", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + binary.LittleEndian.PutUint64(e.TransactionContext.Accounts.Accounts[1].Data[136:], math.MaxUint64-99999) + }, want: InstrErrArithmeticOverflow, cpi: true}, + {name: "lamports_overflow", mutate: func(e *ExecutionCtx, _ *[]AccountMeta, _ *[]byte) { + e.TransactionContext.Accounts.Accounts[1].Lamports = math.MaxUint64 + }, want: InstrErrArithmeticOverflow, cpi: true}, + } { + t.Run(tc.name, func(t *testing.T) { + execCtx, metas := newVoteRewardsTestContext(t, 123) + data := binary.LittleEndian.AppendUint32(nil, 19) + data = binary.LittleEndian.AppendUint64(data, 100000) + if tc.mutate != nil { + tc.mutate(execCtx, &metas, &data) + } + txCtx := execCtx.TransactionContext + beforeVote, beforeSource := txCtx.Accounts.Accounts[1].Clone(), txCtx.Accounts.Accounts[2].Clone() + err := execCtx.ProcessInstruction(data, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{0}) + wantCU := uint64(2100) + if tc.cpi { + wantCU += 150 + } + require.Equal(t, wantCU, execCtx.ComputeMeter.Used()) + if tc.want != nil { + require.ErrorIs(t, err, tc.want) + require.Equal(t, beforeVote.Data, txCtx.Accounts.Accounts[1].Data, "failed deposit must not update pending rewards") + require.Empty(t, execCtx.ModifiedVoteStates) + // The instruction layer can retain CPI effects on failure. Transaction + // rollback is verified separately through replay's publication path. + return + } + require.NoError(t, err) + deposit := binary.LittleEndian.Uint64(data[4:]) + afterVote, afterSource := txCtx.Accounts.Accounts[1], txCtx.Accounts.Accounts[2] + require.Equal(t, beforeVote.Lamports+deposit, afterVote.Lamports) + require.Equal(t, beforeSource.Lamports-deposit, afterSource.Lamports) + expected := beforeVote.Clone() + if len(expected.Data) < VoteStateV4Size { + expected.Data = append(expected.Data, make([]byte, VoteStateV4Size-len(expected.Data))...) + } + pending := binary.LittleEndian.Uint64(expected.Data[136:]) + deposit + binary.LittleEndian.PutUint64(expected.Data[136:], pending) + require.Equal(t, expected.Data, afterVote.Data, "preserve every other field and trailing byte") + require.Equal(t, pending, execCtx.ModifiedVoteStates[afterVote.Key].V4.PendingDelegatorRewards) + require.Len(t, execCtx.InnerInstrs, 1) + require.Equal(t, uint8(3), execCtx.InnerInstrs[0].ProgramIdIndex) + require.Equal(t, []byte{2, 1}, execCtx.InnerInstrs[0].Accounts) + transfer := binary.LittleEndian.AppendUint32(nil, 2) + transfer = binary.LittleEndian.AppendUint64(transfer, deposit) + require.Equal(t, transfer, execCtx.InnerInstrs[0].Data) + if deposit > 0 { + require.NoError(t, execCtx.ProcessInstruction(data, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{0})) + require.Equal(t, beforeVote.Lamports+2*deposit, txCtx.Accounts.Accounts[1].Lamports) + require.Equal(t, pending+deposit, execCtx.ModifiedVoteStates[afterVote.Key].V4.PendingDelegatorRewards) + } + }) + } +} + +func TestVoteWithdrawReservesDelegatorRewards(t *testing.T) { + for _, tc := range []struct { + name string + pending, remaining uint64 + close, recent, sharingDisabled bool + want error + }{ + {name: "exact_reserved_balance", pending: 500000, remaining: 500000}, + {name: "spends_reserved_lamport", pending: 500000, remaining: 499999, want: InstrErrInsufficientFunds}, + {name: "close_with_pending", pending: 500000, close: true, want: InstrErrInsufficientFunds}, + {name: "pending_precedes_active_close", pending: 500000, close: true, recent: true, want: InstrErrInsufficientFunds}, + {name: "rent_plus_pending_overflow", pending: math.MaxUint64, remaining: 500000, want: InstrErrArithmeticOverflow}, + {name: "no_pending_rent_boundary", remaining: 0}, + {name: "close_without_pending", close: true}, + {name: "active_close_without_pending", close: true, recent: true, want: VoteErrActiveVoteAccountClose}, + {name: "reserve_even_if_gate_disabled", pending: 500000, remaining: 499999, sharingDisabled: true, want: InstrErrInsufficientFunds}, + } { + t.Run(tc.name, func(t *testing.T) { + execCtx, _ := newVoteRewardsTestContext(t, tc.pending) + if tc.sharingDisabled { + execCtx.Features.DisableFeature(features.BlockRevenueSharing) + } + txCtx := execCtx.TransactionContext + vote, recipient, withdrawer := txCtx.Accounts.Accounts[1], txCtx.Accounts.Accounts[2], txCtx.Accounts.Accounts[4] + if tc.recent { + v, err := UnmarshalVersionedVoteState(vote.Data) + require.NoError(t, err) + v.V4.EpochCredits[0].Epoch = 1058 + require.NoError(t, WriteVersionedVoteStateInPlace(vote.Data, v)) + } + before := vote.Clone() + recipientBefore := recipient.Lamports + rent := txCtx.Rent + clock := SysvarClock{Epoch: 1058} + old := SysvarCache + SysvarCache.Rent.Sysvar = &rent + SysvarCache.Clock.Sysvar = &clock + t.Cleanup(func() { SysvarCache = old }) + remaining := rent.MinimumBalance(uint64(len(vote.Data))) + tc.remaining + if tc.close { + remaining = 0 + } + amount := vote.Lamports - remaining + data := binary.LittleEndian.AppendUint32(nil, 3) + data = binary.LittleEndian.AppendUint64(data, amount) + metas := []AccountMeta{{Pubkey: vote.Key, IsWritable: true}, {Pubkey: recipient.Key, IsWritable: true}, {Pubkey: withdrawer.Key, IsSigner: true}} + err := execCtx.ProcessInstruction(data, InstructionAcctsFromAccountMetas(metas, txCtx.Accounts), []uint64{0}) + if tc.want != nil { + require.ErrorIs(t, err, tc.want) + require.Equal(t, before, txCtx.Accounts.Accounts[1]) + require.Equal(t, recipientBefore, recipient.Lamports) + return + } + require.NoError(t, err) + require.Equal(t, remaining, vote.Lamports) + require.Equal(t, recipientBefore+amount, recipient.Lamports) + if !tc.close { + require.Equal(t, before.Data, vote.Data) + } + }) + } +} + +// Keep the gate keyed by the deployed address, not the former placeholder. +func TestVoteRewardsFeatureAddress(t *testing.T) { + require.Equal(t, solana.MustPublicKeyFromBase58("7MYx95UBiJufqnumyN7HfskJ9vKdcGMmhreVguqrE97K"), solana.PublicKey(features.BlockRevenueSharing.Address)) +} diff --git a/pkg/sealevel/vote_state.go b/pkg/sealevel/vote_state.go index 60e8574a..1bafa7bd 100644 --- a/pkg/sealevel/vote_state.go +++ b/pkg/sealevel/vote_state.go @@ -1836,37 +1836,30 @@ func newVoteState4FromCurrent(vs *VoteState, votePubkey solana.PublicKey) *VoteS return vs4 } -func setVoteAccountState(execCtx *ExecutionCtx, acct *BorrowedAccount, voteState *VoteState, f features.Features) error { - var err error - if f.IsActive(features.VoteStateV4) { - vsz := VoteStateV3Size - resizeNeeded := len(acct.Data()) < vsz - - resizeRentExempt := acct.IsRentExemptAtDataLength(uint64(vsz)) - resizeFailed := false - - if resizeNeeded && resizeRentExempt { - err = acct.SetDataLength(VoteStateV3Size, f) - if err != nil { - resizeFailed = true - } +func setVoteAccountStateV4(execCtx *ExecutionCtx, acct *BorrowedAccount, voteState *VoteState4, f features.Features) error { + if len(acct.Data()) < VoteStateV4Size { + // SIMD-0185 requires a rent-exempt resize, with no legacy fallback. + if !acct.IsRentExemptAtDataLength(VoteStateV4Size) { + return InstrErrAccountNotRentExempt } - - // V4: NO V1_14_11 fallback — error on resize failure (per SIMD-0185) - if resizeNeeded && (!resizeRentExempt || resizeFailed) { + if err := acct.SetDataLength(VoteStateV4Size, f); err != nil { return InstrErrAccountNotRentExempt } + } + versioned := &VoteStateVersions{Type: VoteStateVersionV4, V4: *voteState} + execCtx.AddModifiedVoteState(acct.Key(), versioned) + voteStateBytes, err := marshalVersionedVoteState(versioned) + defer voteStateBufPool.Put(voteStateBytes) + if err != nil { + return err + } + return acct.SetState(f, voteStateBytes) +} - vs4 := newVoteState4FromCurrent(voteState, acct.Key()) - newVersioned := &VoteStateVersions{Type: VoteStateVersionV4} - newVersioned.V4 = *vs4 - execCtx.AddModifiedVoteState(acct.Key(), newVersioned) - voteStateBytes, err := marshalVersionedVoteState(newVersioned) - defer voteStateBufPool.Put(voteStateBytes) - if err != nil { - return err - } - return acct.SetState(f, voteStateBytes) +func setVoteAccountState(execCtx *ExecutionCtx, acct *BorrowedAccount, voteState *VoteState, f features.Features) error { + var err error + if f.IsActive(features.VoteStateV4) { + return setVoteAccountStateV4(execCtx, acct, newVoteState4FromCurrent(voteState, acct.Key()), f) } else if f.IsActive(features.VoteStateAddVoteLatency) { vsz := VoteStateV3Size resizeNeeded := len(acct.Data()) < vsz