From 94230504c845379966c05268bc3d8bb2d875a297 Mon Sep 17 00:00:00 2001 From: envestcc Date: Mon, 13 Jul 2026 10:31:14 +0800 Subject: [PATCH] feat(rewarding): add AutoDeposit contract-read bridge for IIP-59 compound routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a read-only bridge to the on-chain AutoDeposit contract so PR 3' (distributeVoterReward rework) can decide per-voter compound-vs-credit routing at epoch reward distribution time per IIP-59 §3.6. - New action/protocol/rewarding/autodeposit package: * Bridge: stateless wrapper around the pinned AutoDeposit contract, exposing LookupBucket(ctx, reader, voter) → (bucketID, present, err). * ContractReader: package-local view-call primitive so unit tests can stand in a fake without pulling the EVM simulator into scope. * Route / Decision types with wire format shared with PR 4.7's DelegateDistributed.routings[] encoding. * IsBucketEligibleForCompound helper applies §3.6 preconditions 2-4 (bucket exists, native bucket, AutoStake=true, active, Owner==voter). * Structurally mirrors PR 4.5's delegateprofile.Bridge so both bridges present a uniform surface to callers. - Per-item fallback semantic (IIP-59 §3.6): malformed on-chain data (negative int256, oversized-for-uint64 value) silent-fallbacks to RouteCredit rather than erroring the block. Only wiring bugs (nil reader, nil voter, ABI drift, RPC error) hard-error. - New genesis field Blockchain.AutoDepositContractAddress with empty default (compound routing inactive → every voter share → unclaimedBalance). - New exported staking.VoteBucket.IsUnstaked() and IsNative() wrappers so the autodeposit package can gate eligibility without duplicating the staking-internal invariants. AutoDeposit is read live at drain time, not frozen at PutPollResult (unlike DelegateProfile) — see IIP-59 §3.6. Ships independently of the earlier stack (PR 4.5 / PR 2') since it shares no code with them. --- action/protocol/rewarding/autodeposit/abi.go | 37 +++ .../protocol/rewarding/autodeposit/bridge.go | 168 ++++++++++++ .../rewarding/autodeposit/bridge_test.go | 255 ++++++++++++++++++ .../rewarding/autodeposit/eligibility.go | 45 ++++ .../rewarding/autodeposit/eligibility_test.go | 103 +++++++ action/protocol/staking/vote_bucket.go | 12 + blockchain/genesis/genesis.go | 6 + 7 files changed, 626 insertions(+) create mode 100644 action/protocol/rewarding/autodeposit/abi.go create mode 100644 action/protocol/rewarding/autodeposit/bridge.go create mode 100644 action/protocol/rewarding/autodeposit/bridge_test.go create mode 100644 action/protocol/rewarding/autodeposit/eligibility.go create mode 100644 action/protocol/rewarding/autodeposit/eligibility_test.go diff --git a/action/protocol/rewarding/autodeposit/abi.go b/action/protocol/rewarding/autodeposit/abi.go new file mode 100644 index 0000000000..75803940af --- /dev/null +++ b/action/protocol/rewarding/autodeposit/abi.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package autodeposit + +// abiJSON is a trimmed subset of the AutoDeposit contract's ABI. Only the +// entries the bridge actually calls are kept — currently the read-only +// bucket(address) getter. The full ABI (register/unregister/pause/paused +// /registrants/buckets/owner) lives in iotex-analyser under +// plugins/hermes/hermes_abi.go's AutoDepositABI and is what Hermes uses +// off-chain; this bridge only needs to consult the compound-preference +// mapping, so we keep the on-chain surface minimal. +const abiJSON = `[ + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "bucket", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +]` diff --git a/action/protocol/rewarding/autodeposit/bridge.go b/action/protocol/rewarding/autodeposit/bridge.go new file mode 100644 index 0000000000..238866997a --- /dev/null +++ b/action/protocol/rewarding/autodeposit/bridge.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +// Package autodeposit bridges the on-chain AutoDeposit contract into the +// IIP-59 protocol-native voter reward drain path. +// +// distributeToVoters (added in PR 3', still unwritten) calls the bridge +// once per voter share at epoch close and routes the share based on the +// returned bucket ID + eligibility check. The bridge is deliberately +// stateless and takes a ContractReader adapter so unit tests can stand in +// a fake without pulling the EVM simulator into scope. +// +// Unlike the DelegateProfile bridge (PR 4.5), AutoDeposit is read live at +// drain time, not frozen at PutPollResult — see IIP-59 §3.6. +package autodeposit + +import ( + "context" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" +) + +// fieldBucketFn is the ABI method name of the AutoDeposit view getter that +// returns a voter's registered compound-target bucket ID. +const fieldBucketFn = "bucket" + +// Route classifies where a voter's per-epoch share is delivered. The wire +// value matches IIP-59 §3.5's DelegateDistributed.routings[] encoding, so +// PR 4.7's log emitter and this bridge share a single source of truth. +type Route uint8 + +const ( + // RouteCredit credits the share to the voter's unclaimed balance for + // pull-claim via the existing ClaimFromRewardingFund action. + RouteCredit Route = 0 + // RouteCompound calls into the native staking AddDeposit path against + // the voter's registered bucket. + RouteCompound Route = 1 +) + +// Decision is the per-voter routing verdict produced from LookupBucket plus +// the caller-side eligibility check. +type Decision struct { + Route Route + BucketID uint64 // valid iff Route == RouteCompound +} + +// ContractReader is the read-only view-call primitive the bridge depends on. +// Production wires an adapter around evm.SimulateExecution; tests supply an +// in-memory fake. Kept package-local (rather than depending on the +// delegateprofile package's identical type) so this bridge can ship +// independently of PR 4.5. +type ContractReader interface { + Read(ctx context.Context, contract string, callData []byte) ([]byte, error) +} + +// ContractReaderFunc lets a plain function satisfy ContractReader. +type ContractReaderFunc func(ctx context.Context, contract string, callData []byte) ([]byte, error) + +// Read implements ContractReader. +func (f ContractReaderFunc) Read(ctx context.Context, contract string, callData []byte) ([]byte, error) { + return f(ctx, contract, callData) +} + +// ErrEmptyContractAddress is returned when the bridge is constructed +// without a target contract. +var ErrEmptyContractAddress = errors.New("autodeposit: empty contract address") + +// Bridge is the reusable read-only wrapper around a specific AutoDeposit +// contract deployment. Construct once at protocol init with the network's +// pinned contract address; call LookupBucket per voter at drain time. +type Bridge struct { + abi abi.ABI + contract string +} + +// New constructs a Bridge targeting contract. contract must be a valid +// IoTeX bech32 address; the caller is responsible for pinning it to the +// network-appropriate mainnet/testnet value. +func New(contract string) (*Bridge, error) { + if contract == "" { + return nil, ErrEmptyContractAddress + } + if _, err := address.FromString(contract); err != nil { + return nil, errors.Wrap(err, "autodeposit: invalid contract address") + } + parsed, err := abi.JSON(strings.NewReader(abiJSON)) + if err != nil { + return nil, errors.Wrap(err, "autodeposit: failed to parse ABI") + } + return &Bridge{abi: parsed, contract: contract}, nil +} + +// Contract returns the target contract address, mostly for logging. +func (b *Bridge) Contract() string { return b.contract } + +// LookupBucket reads AutoDeposit.bucket(voter) and normalises the result +// to (uint64, present, error): +// +// - (bucketID, true, nil): voter is registered with a strictly positive +// bucket ID that fits in uint64. +// - (0, false, nil): voter is unregistered — the contract returned +// zero, a negative int256, or a value larger than +// 2^63-1. All three are treated identically so PR 3' +// routes them to RouteCredit without further branching. +// - (0, false, err): wiring failure (nil reader, nil voter, ABI +// mismatch, or reader RPC error). Callers MUST log +// and route to RouteCredit; see IIP-59 §3.6: +// "a failed contract call falls through to +// unclaimedBalance". +// +// The malformed-value silent fallback follows the consensus-path rule: +// one voter's malformed on-chain registration must not halt the block. +// Wiring bugs (nil reader / nil voter) stay hard errors — those indicate +// a caller-side mistake, not on-chain data. +func (b *Bridge) LookupBucket( + ctx context.Context, + reader ContractReader, + voter address.Address, +) (uint64, bool, error) { + if reader == nil { + return 0, false, errors.New("autodeposit: nil ContractReader") + } + if voter == nil { + return 0, false, errors.New("autodeposit: nil voter address") + } + + voterEth := common.BytesToAddress(voter.Bytes()) + callData, err := b.abi.Pack(fieldBucketFn, voterEth) + if err != nil { + return 0, false, errors.Wrap(err, "autodeposit: pack bucket(address)") + } + raw, err := reader.Read(ctx, b.contract, callData) + if err != nil { + return 0, false, errors.Wrapf(err, "autodeposit: read bucket(%s)", voter.String()) + } + out, err := b.abi.Unpack(fieldBucketFn, raw) + if err != nil { + return 0, false, errors.Wrap(err, "autodeposit: unpack bucket(address)") + } + if len(out) != 1 { + return 0, false, errors.Errorf("autodeposit: bucket(address) expected 1 return value, got %d", len(out)) + } + bigVal, ok := out[0].(*big.Int) + if !ok { + return 0, false, errors.Errorf("autodeposit: bucket(address) expected *big.Int, got %T", out[0]) + } + // Non-positive means unregistered per IIP-59 §3.6 precondition 1. + // Solidity's default-zero for unset mappings and negative sentinels + // both land here. + if bigVal.Sign() <= 0 { + return 0, false, nil + } + // Values that don't fit uint64 are malformed on-chain data. Silent + // fallback to unregistered so PR 3' still makes progress on other + // voters — see feedback-consensus-fallback-vs-halt. + if !bigVal.IsUint64() { + return 0, false, nil + } + return bigVal.Uint64(), true, nil +} diff --git a/action/protocol/rewarding/autodeposit/bridge_test.go b/action/protocol/rewarding/autodeposit/bridge_test.go new file mode 100644 index 0000000000..566de4298a --- /dev/null +++ b/action/protocol/rewarding/autodeposit/bridge_test.go @@ -0,0 +1,255 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package autodeposit + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// mainnetContract is the pinned mainnet AutoDeposit deployment. Tests use +// it verbatim so a rename in production would be caught here too. +const mainnetContract = "io108ckwzlzpkhva7cnfceajlu7wu6ql5kq95uat9" + +// fakeStore is an in-memory stand-in for the AutoDeposit contract's +// buckets(address) storage. Each entry maps a voter to the int256 the +// contract would return from bucket(voter). Missing entries return zero, +// matching Solidity's default-mapping behaviour. +type fakeStore struct { + values map[string]*big.Int +} + +func newFakeStore() *fakeStore { return &fakeStore{values: map[string]*big.Int{}} } + +func (s *fakeStore) set(voter address.Address, bucketID *big.Int) { + s.values[storeKey(voter)] = new(big.Int).Set(bucketID) +} + +func storeKey(voter address.Address) string { + return common.BytesToAddress(voter.Bytes()).Hex() +} + +// reader turns fakeStore into a ContractReader by decoding the incoming +// bucket(address) call, looking up the value, and packing it the same way +// go-ethereum's abi package does. +func (s *fakeStore) reader(t *testing.T) ContractReader { + t.Helper() + parsed, err := abi.JSON(strings.NewReader(abiJSON)) + require.NoError(t, err) + return ContractReaderFunc(func(_ context.Context, contract string, callData []byte) ([]byte, error) { + if contract == "" { + return nil, errors.New("empty contract") + } + if len(callData) < 4 { + return nil, errors.New("truncated call data") + } + method, err := parsed.MethodById(callData[:4]) + if err != nil { + return nil, err + } + args, err := method.Inputs.Unpack(callData[4:]) + if err != nil { + return nil, err + } + require.Equal(t, fieldBucketFn, method.Name) + require.Len(t, args, 1) + voterEth := args[0].(common.Address) + addr, err := address.FromBytes(voterEth.Bytes()) + if err != nil { + return nil, err + } + value, ok := s.values[storeKey(addr)] + if !ok { + value = big.NewInt(0) + } + return method.Outputs.Pack(value) + }) +} + +func TestNew(t *testing.T) { + r := require.New(t) + + t.Run("empty contract rejected", func(t *testing.T) { + _, err := New("") + r.ErrorIs(err, ErrEmptyContractAddress) + }) + + t.Run("garbage address rejected", func(t *testing.T) { + _, err := New("not-a-bech32-address") + r.Error(err) + }) + + t.Run("valid address accepted", func(t *testing.T) { + b, err := New(mainnetContract) + r.NoError(err) + r.Equal(mainnetContract, b.Contract()) + }) +} + +func TestLookupBucket_Registered(t *testing.T) { + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(1) + store := newFakeStore() + store.set(voter, big.NewInt(42)) + + bucketID, present, err := b.LookupBucket(context.Background(), store.reader(t), voter) + r.NoError(err) + r.True(present) + r.Equal(uint64(42), bucketID) +} + +func TestLookupBucket_Unregistered(t *testing.T) { + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(2) + store := newFakeStore() // voter never registered + + bucketID, present, err := b.LookupBucket(context.Background(), store.reader(t), voter) + r.NoError(err) + r.False(present) + r.Zero(bucketID) +} + +func TestLookupBucket_ExplicitZero(t *testing.T) { + // A voter who called register(0) is indistinguishable from an + // unregistered voter — the spec's "non-zero" precondition intentionally + // treats bucket ID 0 as ineligible, matching Hermes' historical + // interpretation. + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(3) + store := newFakeStore() + store.set(voter, big.NewInt(0)) + + bucketID, present, err := b.LookupBucket(context.Background(), store.reader(t), voter) + r.NoError(err) + r.False(present, "bucket ID 0 must route to credit") + r.Zero(bucketID) +} + +func TestLookupBucket_NegativeInt256(t *testing.T) { + // int256 allows negative sentinels; malformed on-chain data must + // degrade to unregistered rather than error out (per + // feedback-consensus-fallback-vs-halt). + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(4) + store := newFakeStore() + store.set(voter, big.NewInt(-1)) + + bucketID, present, err := b.LookupBucket(context.Background(), store.reader(t), voter) + r.NoError(err) + r.False(present, "negative bucket ID must degrade to credit, not error") + r.Zero(bucketID) +} + +func TestLookupBucket_ValueTooLargeForUint64(t *testing.T) { + // A bucket ID that doesn't fit uint64 is malformed on-chain data. Same + // degradation rule: silent fallback to credit, no error. + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(5) + store := newFakeStore() + huge := new(big.Int).Lsh(big.NewInt(1), 70) // 2^70 + store.set(voter, huge) + + bucketID, present, err := b.LookupBucket(context.Background(), store.reader(t), voter) + r.NoError(err) + r.False(present, "oversized bucket ID must degrade to credit") + r.Zero(bucketID) +} + +func TestLookupBucket_ReaderErrorPropagates(t *testing.T) { + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(6) + failReader := ContractReaderFunc(func(context.Context, string, []byte) ([]byte, error) { + return nil, errors.New("rpc down") + }) + + _, present, err := b.LookupBucket(context.Background(), failReader, voter) + r.Error(err) + r.False(present) + r.Contains(err.Error(), "rpc down") + r.Contains(err.Error(), voter.String(), "error must name the offending voter") +} + +func TestLookupBucket_NilReaderRejected(t *testing.T) { + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + _, _, err = b.LookupBucket(context.Background(), nil, identityset.Address(1)) + r.Error(err) +} + +func TestLookupBucket_NilVoterRejected(t *testing.T) { + // A nil voter address is a caller bug — silently skipping would let a + // voter slip past the drain without a routing decision. Must fail loud. + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + store := newFakeStore() + _, _, err = b.LookupBucket(context.Background(), store.reader(t), nil) + r.Error(err) +} + +func TestLookupBucket_CallDataShape(t *testing.T) { + // Guard against ABI drift: the encoded call data's first four bytes + // must equal keccak256("bucket(address)")[:4], and the voter address + // must occupy the trailing 20 bytes of the following 32-byte word. + r := require.New(t) + b, err := New(mainnetContract) + r.NoError(err) + + voter := identityset.Address(1) + captured := make([][]byte, 0, 1) + capReader := ContractReaderFunc(func(_ context.Context, _ string, data []byte) ([]byte, error) { + captured = append(captured, append([]byte(nil), data...)) + // Return a valid packed uint256(0) so LookupBucket completes. + parsed, _ := abi.JSON(strings.NewReader(abiJSON)) + return parsed.Methods[fieldBucketFn].Outputs.Pack(big.NewInt(0)) + }) + + _, _, err = b.LookupBucket(context.Background(), capReader, voter) + r.NoError(err) + r.Len(captured, 1) + + callData := captured[0] + r.GreaterOrEqual(len(callData), 4+32) + expectedSelector := crypto.Keccak256([]byte("bucket(address)"))[:4] + r.Equal(expectedSelector, callData[:4], "selector must match bucket(address)") + + // Word after selector: 12 zero bytes + 20-byte address. + word := callData[4 : 4+32] + r.Equal(make([]byte, 12), word[:12], "address word must be left-padded with zeroes") + r.Equal(voter.Bytes(), word[12:], "trailing 20 bytes must be the voter address") +} diff --git a/action/protocol/rewarding/autodeposit/eligibility.go b/action/protocol/rewarding/autodeposit/eligibility.go new file mode 100644 index 0000000000..dd2a8d5208 --- /dev/null +++ b/action/protocol/rewarding/autodeposit/eligibility.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package autodeposit + +import ( + "github.com/iotexproject/iotex-address/address" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" +) + +// IsBucketEligibleForCompound applies IIP-59 §3.6's preconditions 2-4: +// +// 2. The bucket exists in staking state (nil bucket ⇒ ineligible). +// 3. The bucket is a native bucket. LSD / contract-staking buckets are +// owned by the staking contract, so bucket.Owner never equals the +// token holder — they are structurally ineligible; parity with today's +// Hermes behaviour (see IIP-59 §3.6 "LSD parity"). +// 4. bucket.AutoStake is true, the bucket is currently active +// (not unstaked), and bucket.Owner byte-equals voter. +// +// Precondition 1 (non-zero bucket ID from the AutoDeposit contract) is +// handled by Bridge.LookupBucket; this helper takes over once a candidate +// bucket has been fetched from staking state. +// +// The helper is total: nil bucket, nil voter, or any missing precondition +// returns false. It never returns an error so callers can chain it into +// per-voter routing without branching. +func IsBucketEligibleForCompound(bucket *staking.VoteBucket, voter address.Address) bool { + if bucket == nil || voter == nil { + return false + } + if !bucket.IsNative() { + return false + } + if !bucket.AutoStake { + return false + } + if bucket.IsUnstaked() { + return false + } + return address.Equal(bucket.Owner, voter) +} diff --git a/action/protocol/rewarding/autodeposit/eligibility_test.go b/action/protocol/rewarding/autodeposit/eligibility_test.go new file mode 100644 index 0000000000..f82beb0e4f --- /dev/null +++ b/action/protocol/rewarding/autodeposit/eligibility_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package autodeposit + +import ( + "math/big" + "testing" + "time" + + "github.com/iotexproject/iotex-address/address" + "github.com/stretchr/testify/require" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// newActiveNativeBucket returns a bucket that passes every eligibility +// precondition — the "happy path" bucket for the tests below to then +// mutate one field at a time. +func newActiveNativeBucket(owner, candidate address.Address) *staking.VoteBucket { + return staking.NewVoteBucket( + candidate, + owner, + big.NewInt(1_000_000), + 30, + time.Now().UTC(), + true, // AutoStake + ) +} + +func TestIsBucketEligibleForCompound_NilBucket(t *testing.T) { + r := require.New(t) + voter := identityset.Address(1) + r.False(IsBucketEligibleForCompound(nil, voter)) +} + +func TestIsBucketEligibleForCompound_NilVoter(t *testing.T) { + // Guard against caller-side nil address slipping through: callers of + // LookupBucket already reject nil, but the eligibility helper must + // stand on its own since PR 3' calls it independently. + r := require.New(t) + owner := identityset.Address(1) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + r.False(IsBucketEligibleForCompound(bucket, nil)) +} + +func TestIsBucketEligibleForCompound_WrongOwner(t *testing.T) { + r := require.New(t) + owner := identityset.Address(1) + stranger := identityset.Address(3) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + r.False(IsBucketEligibleForCompound(bucket, stranger)) +} + +func TestIsBucketEligibleForCompound_AutoStakeFalse(t *testing.T) { + // Even with the correct owner, a non-auto-stake bucket must not be + // compounded — parity with Hermes' current filter. + r := require.New(t) + owner := identityset.Address(1) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + bucket.AutoStake = false + r.False(IsBucketEligibleForCompound(bucket, owner)) +} + +func TestIsBucketEligibleForCompound_Unstaked(t *testing.T) { + // An unstaked bucket can no longer accept deposits — compound routing + // must exclude it and let the share flow to unclaimedBalance. + r := require.New(t) + owner := identityset.Address(1) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + bucket.UnstakeStartTime = bucket.StakeStartTime.Add(time.Hour) + r.True(bucket.IsUnstaked(), "sanity: fixture must actually be unstaked") + r.False(IsBucketEligibleForCompound(bucket, owner)) +} + +func TestIsBucketEligibleForCompound_ContractStaking(t *testing.T) { + // Contract-staking (LSD) buckets are owned by the staking contract, + // not the token holder — bucket.Owner would never equal the voter in + // production. Assert the ineligibility even if a synthetic bucket + // were to somehow satisfy the Owner check. + r := require.New(t) + owner := identityset.Address(1) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + bucket.ContractAddress = "io1abcdefghijklmnopqrstuvwxyz1234567890abc" + r.False(bucket.IsNative(), "sanity: fixture must be non-native") + r.False(IsBucketEligibleForCompound(bucket, owner)) +} + +func TestIsBucketEligibleForCompound_HappyPath(t *testing.T) { + r := require.New(t) + owner := identityset.Address(1) + candidate := identityset.Address(2) + bucket := newActiveNativeBucket(owner, candidate) + r.True(IsBucketEligibleForCompound(bucket, owner)) +} diff --git a/action/protocol/staking/vote_bucket.go b/action/protocol/staking/vote_bucket.go index c425146d1c..3f0c76f56c 100644 --- a/action/protocol/staking/vote_bucket.go +++ b/action/protocol/staking/vote_bucket.go @@ -204,10 +204,22 @@ func (vb *VoteBucket) isUnstaked() bool { return vb.UnstakeStartBlockHeight < MaxDurationNumber } +// IsUnstaked exposes the internal isUnstaked check to callers outside the +// staking package. Consumers in the rewarding path (IIP-59 compound +// routing) need to gate compound-eligibility on the bucket still being +// active; this is that gate. +func (vb *VoteBucket) IsUnstaked() bool { return vb.isUnstaked() } + func (vb *VoteBucket) isNative() bool { return vb.ContractAddress == "" } +// IsNative reports whether the bucket is a native staking bucket (as +// opposed to a contract-staking bucket). LSD / contract-staking buckets +// are owned by the staking contract rather than the underlying holder, +// which is why IIP-59 compound routing excludes them. +func (vb *VoteBucket) IsNative() bool { return vb.isNative() } + // Deserialize deserializes bytes into bucket count func (tc *totalBucketCount) Deserialize(data []byte) error { tc.count = byteutil.BytesToUint64BigEndian(data) diff --git a/blockchain/genesis/genesis.go b/blockchain/genesis/genesis.go index 38b0f91dfa..6136fd382c 100644 --- a/blockchain/genesis/genesis.go +++ b/blockchain/genesis/genesis.go @@ -393,6 +393,12 @@ type ( // ToBeEnabledBlockHeight is a fake height that acts as a gating factor for WIP features // upon next release, change IsToBeEnabled() to IsNextHeight() for features to be released ToBeEnabledBlockHeight uint64 `yaml:"toBeEnabledHeight"` + // AutoDepositContractAddress is the IoTeX bech32 address of the + // AutoDeposit contract from which IIP-59 reads per-voter compound + // preferences at epoch reward distribution time. Empty means + // compound routing is inactive and every voter share is credited + // to the voter's unclaimed balance for pull-claim. + AutoDepositContractAddress string `yaml:"autoDepositContractAddress"` } // Account contains the configs for account protocol Account struct {