diff --git a/api/v1alpha1/seinetwork_types.go b/api/v1alpha1/seinetwork_types.go index c09b40b..4099af8 100644 --- a/api/v1alpha1/seinetwork_types.go +++ b/api/v1alpha1/seinetwork_types.go @@ -148,6 +148,32 @@ type GenesisAccount struct { // Balance is the initial balance in coin notation (e.g. "1000000usei"). // +kubebuilder:validation:MinLength=1 Balance string `json:"balance"` + + // Vesting, when set, locks part of Balance on an unlock schedule instead + // of funding a fully-spendable account; nil funds a standard account. + // The locked coins still count toward the balance and can be staked, but + // cannot be transferred until they unlock. + // +optional + Vesting *GenesisAccountVesting `json:"vesting,omitempty"` +} + +// GenesisAccountVesting locks part of a GenesisAccount's Balance on an unlock +// schedule that completes at EndTime. +type GenesisAccountVesting struct { + // Amount is the vesting-locked portion of the account's Balance, in coin + // notation (e.g. "1000000usei"). Must not exceed Balance. + // +kubebuilder:validation:MinLength=1 + Amount string `json:"amount"` + + // EndTime is the unix timestamp at which the locked Amount fully unlocks. + // Must be after the network's genesis time. + // +kubebuilder:validation:Minimum=1 + EndTime int64 `json:"endTime"` + + // Delayed unlocks the full Amount all at once at EndTime. The default + // (false) unlocks it linearly from the network's genesis time to EndTime. + // +optional + Delayed bool `json:"delayed,omitempty"` } // --------------------------------------------------------------------------- diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a8cbb64..1a926cd 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -267,6 +267,11 @@ func (in *FullNodeSpec) DeepCopy() *FullNodeSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GenesisAccount) DeepCopyInto(out *GenesisAccount) { *out = *in + if in.Vesting != nil { + in, out := &in.Vesting, &out.Vesting + *out = new(GenesisAccountVesting) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GenesisAccount. @@ -279,13 +284,30 @@ func (in *GenesisAccount) DeepCopy() *GenesisAccount { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GenesisAccountVesting) DeepCopyInto(out *GenesisAccountVesting) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GenesisAccountVesting. +func (in *GenesisAccountVesting) DeepCopy() *GenesisAccountVesting { + if in == nil { + return nil + } + out := new(GenesisAccountVesting) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GenesisCeremonyConfig) DeepCopyInto(out *GenesisCeremonyConfig) { *out = *in if in.Accounts != nil { in, out := &in.Accounts, &out.Accounts *out = make([]GenesisAccount, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Overrides != nil { in, out := &in.Overrides, &out.Overrides diff --git a/config/crd/sei.io_seinetworks.yaml b/config/crd/sei.io_seinetworks.yaml index cecb12b..6bb439d 100644 --- a/config/crd/sei.io_seinetworks.yaml +++ b/config/crd/sei.io_seinetworks.yaml @@ -167,6 +167,35 @@ spec: (e.g. "1000000usei"). minLength: 1 type: string + vesting: + description: |- + Vesting, when set, locks part of Balance on an unlock schedule instead + of funding a fully-spendable account; nil funds a standard account. + The locked coins still count toward the balance and can be staked, but + cannot be transferred until they unlock. + properties: + amount: + description: |- + Amount is the vesting-locked portion of the account's Balance, in coin + notation (e.g. "1000000usei"). Must not exceed Balance. + minLength: 1 + type: string + delayed: + description: |- + Delayed unlocks the full Amount all at once at EndTime. The default + (false) unlocks it linearly from the network's genesis time to EndTime. + type: boolean + endTime: + description: |- + EndTime is the unix timestamp at which the locked Amount fully unlocks. + Must be after the network's genesis time. + format: int64 + minimum: 1 + type: integer + required: + - amount + - endTime + type: object required: - address - balance diff --git a/go.mod b/go.mod index c6eaf13..09dde97 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/google/uuid v1.6.0 github.com/onsi/gomega v1.39.1 github.com/sei-protocol/sei-config v0.0.23 - github.com/sei-protocol/seictl v0.0.64 + github.com/sei-protocol/seictl v0.0.68 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 go.opentelemetry.io/otel/exporters/prometheus v0.65.0 diff --git a/go.sum b/go.sum index 72a2925..6e7af96 100644 --- a/go.sum +++ b/go.sum @@ -172,8 +172,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sei-protocol/sei-config v0.0.23 h1:pGFxRnKoXZLK3Ew/Vd5lgC5RvH9Wo58NnL19CuMgFIk= github.com/sei-protocol/sei-config v0.0.23/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= -github.com/sei-protocol/seictl v0.0.64 h1:GfAA/2br7nDTlpiav1BC7Z2fUS9iv7gXYhECXKClPiY= -github.com/sei-protocol/seictl v0.0.64/go.mod h1:YJYgm3yl1fTpLWdPrpwgAqyONK5IwSZ3Dlw4dzH6U6U= +github.com/sei-protocol/seictl v0.0.68 h1:dCT94Ys4OjiPt5Y6TWqtgJ8GKTiWv7tN87D8HqIQxbk= +github.com/sei-protocol/seictl v0.0.68/go.mod h1:kI3HIAIWzuJSme8LqJH1WZiITrSIx5yDtJJea07Xt8Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/internal/planner/group.go b/internal/planner/group.go index 2ae8d69..81498bd 100644 --- a/internal/planner/group.go +++ b/internal/planner/group.go @@ -34,7 +34,15 @@ func (p *genesisGroupPlanner) BuildPlan( accounts := make([]sidecar.GenesisAccountEntry, len(network.Spec.Genesis.Accounts)) for i, a := range network.Spec.Genesis.Accounts { - accounts[i] = sidecar.GenesisAccountEntry{Address: a.Address, Balance: a.Balance} + entry := sidecar.GenesisAccountEntry{Address: a.Address, Balance: a.Balance} + if a.Vesting != nil { + entry.Vesting = &sidecar.GenesisAccountVesting{ + Amount: a.Vesting.Amount, + EndTime: a.Vesting.EndTime, + Delayed: a.Vesting.Delayed, + } + } + accounts[i] = entry } // Validate at planner-time so bech32 / shape errors hit diff --git a/internal/planner/group_accounts_test.go b/internal/planner/group_accounts_test.go index 6f70c16..daa6c75 100644 --- a/internal/planner/group_accounts_test.go +++ b/internal/planner/group_accounts_test.go @@ -76,6 +76,47 @@ func TestBuildPlan_NilAccountsOmitsField(t *testing.T) { } } +func TestBuildPlan_PropagatesVesting(t *testing.T) { + group := groupWithAccounts([]seiv1alpha1.GenesisAccount{ + { + Address: validSeiAddr, + Balance: "2000000usei", + Vesting: &seiv1alpha1.GenesisAccountVesting{ + Amount: testAccountBalance, + EndTime: 1893456000, + Delayed: true, + }, + }, + {Address: validSeiAddr2, Balance: testBalance1000usei}, // no Vesting: must stay nil, not zero-valued + }) + p, err := ForGroup(group) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + plan, err := p.BuildPlan(group) + if err != nil { + t.Fatalf("BuildPlan: %v", err) + } + + var params sidecar.AssembleAndUploadGenesisTask + if err := json.Unmarshal(plan.Tasks[0].Params.Raw, ¶ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(params.Accounts) != 2 { + t.Fatalf("Accounts: got %d, want 2", len(params.Accounts)) + } + v := params.Accounts[0].Vesting + if v == nil { + t.Fatalf("Accounts[0].Vesting: got nil, want set") + } + if v.Amount != testAccountBalance || v.EndTime != 1893456000 || !v.Delayed { + t.Errorf("Accounts[0].Vesting = %+v", v) + } + if params.Accounts[1].Vesting != nil { + t.Errorf("Accounts[1].Vesting: got %+v, want nil", params.Accounts[1].Vesting) + } +} + func TestBuildPlan_RejectsBadBech32_PlannerTime(t *testing.T) { // Validate() runs at planner time so a bad address surfaces in // kubectl describe rather than burning a sidecar Job pod. diff --git a/manifests/sei.io_seinetworks.yaml b/manifests/sei.io_seinetworks.yaml index cecb12b..6bb439d 100644 --- a/manifests/sei.io_seinetworks.yaml +++ b/manifests/sei.io_seinetworks.yaml @@ -167,6 +167,35 @@ spec: (e.g. "1000000usei"). minLength: 1 type: string + vesting: + description: |- + Vesting, when set, locks part of Balance on an unlock schedule instead + of funding a fully-spendable account; nil funds a standard account. + The locked coins still count toward the balance and can be staked, but + cannot be transferred until they unlock. + properties: + amount: + description: |- + Amount is the vesting-locked portion of the account's Balance, in coin + notation (e.g. "1000000usei"). Must not exceed Balance. + minLength: 1 + type: string + delayed: + description: |- + Delayed unlocks the full Amount all at once at EndTime. The default + (false) unlocks it linearly from the network's genesis time to EndTime. + type: boolean + endTime: + description: |- + EndTime is the unix timestamp at which the locked Amount fully unlocks. + Must be after the network's genesis time. + format: int64 + minimum: 1 + type: integer + required: + - amount + - endTime + type: object required: - address - balance diff --git a/sdk/sei/provider/k8s/k8s_test.go b/sdk/sei/provider/k8s/k8s_test.go index 598e15d..8e5a0ca 100644 --- a/sdk/sei/provider/k8s/k8s_test.go +++ b/sdk/sei/provider/k8s/k8s_test.go @@ -26,6 +26,8 @@ const ( testNet = "chaos-net" testImage = "img:1" + testGenesisAddr = "sei1abc" // non-validator genesis account fixture + rpc0Name = "rpc-0" rpc1Name = "rpc-1" resultKey = "result" diff --git a/sdk/sei/provider/k8s/render.go b/sdk/sei/provider/k8s/render.go index ac55f17..f7011bd 100644 --- a/sdk/sei/provider/k8s/render.go +++ b/sdk/sei/provider/k8s/render.go @@ -58,8 +58,15 @@ func renderNetwork(spec sei.NetworkSpec, namespace string) *seiv1alpha1.SeiNetwo net.Spec.ConfigOverrides = maps.Clone(spec.Config) } for _, a := range spec.Accounts { - net.Spec.Genesis.Accounts = append(net.Spec.Genesis.Accounts, - seiv1alpha1.GenesisAccount{Address: a.Address, Balance: a.Balance}) + acc := seiv1alpha1.GenesisAccount{Address: a.Address, Balance: a.Balance} + if a.Vesting != nil { + acc.Vesting = &seiv1alpha1.GenesisAccountVesting{ + Amount: a.Vesting.Amount, + EndTime: a.Vesting.EndTime, + Delayed: a.Vesting.Delayed, + } + } + net.Spec.Genesis.Accounts = append(net.Spec.Genesis.Accounts, acc) } if spec.SidecarImage != "" { // Pin the seictl sidecar image on this network; the controller propagates diff --git a/sdk/sei/provider/k8s/render_test.go b/sdk/sei/provider/k8s/render_test.go index 4e5780d..7099b81 100644 --- a/sdk/sei/provider/k8s/render_test.go +++ b/sdk/sei/provider/k8s/render_test.go @@ -7,12 +7,41 @@ import ( "github.com/sei-protocol/sei-k8s-controller/sdk/sei" ) +func TestRenderNetwork_PropagatesVesting(t *testing.T) { + spec := sei.NetworkSpec{ + Name: testNet, Image: testImage, Validators: 1, + Accounts: []sei.GenesisAccount{ + { + Address: testGenesisAddr, + Balance: "2000000usei", + Vesting: &sei.GenesisAccountVesting{Amount: "1000000usei", EndTime: 1893456000, Delayed: true}, + }, + {Address: "sei1def", Balance: "100usei"}, // no Vesting: must stay nil, not zero-valued + }, + } + net := renderNetwork(spec, testNS) + + if len(net.Spec.Genesis.Accounts) != 2 { + t.Fatalf("genesis accounts = %+v", net.Spec.Genesis.Accounts) + } + v := net.Spec.Genesis.Accounts[0].Vesting + if v == nil { + t.Fatalf("Accounts[0].Vesting: got nil, want set") + } + if v.Amount != "1000000usei" || v.EndTime != 1893456000 || !v.Delayed { + t.Errorf("Accounts[0].Vesting = %+v", v) + } + if net.Spec.Genesis.Accounts[1].Vesting != nil { + t.Errorf("Accounts[1].Vesting: got %+v, want nil", net.Spec.Genesis.Accounts[1].Vesting) + } +} + func TestRenderNetwork_ChainIDDefaultsToName(t *testing.T) { spec := sei.NetworkSpec{ Name: testNet, Image: testImage, Validators: 4, Genesis: map[string]string{"staking.params.unbonding_time": "60s"}, Config: map[string]string{"app.pruning": "nothing"}, - Accounts: []sei.GenesisAccount{{Address: "sei1abc", Balance: "100usei"}}, + Accounts: []sei.GenesisAccount{{Address: testGenesisAddr, Balance: "100usei"}}, Labels: map[string]string{testRunLabel: testRunID}, DeletionPolicy: sei.DeletionDelete, } @@ -34,7 +63,7 @@ func TestRenderNetwork_ChainIDDefaultsToName(t *testing.T) { if got := net.Spec.ConfigOverrides["app.pruning"]; got != "nothing" { t.Errorf("configOverrides = %v, want app.pruning=nothing", net.Spec.ConfigOverrides) } - if len(net.Spec.Genesis.Accounts) != 1 || net.Spec.Genesis.Accounts[0].Address != "sei1abc" { + if len(net.Spec.Genesis.Accounts) != 1 || net.Spec.Genesis.Accounts[0].Address != testGenesisAddr { t.Errorf("genesis accounts = %+v", net.Spec.Genesis.Accounts) } // DeletionPolicy threads through so an ephemeral chain cascades to its diff --git a/sdk/sei/spec.go b/sdk/sei/spec.go index 22fcb09..7118f3c 100644 --- a/sdk/sei/spec.go +++ b/sdk/sei/spec.go @@ -38,6 +38,18 @@ type NetworkSpec struct { type GenesisAccount struct { Address string Balance string + + // Vesting, when set, locks part of Balance under a vesting schedule + // instead of funding a standard account; nil produces a plain account. + Vesting *GenesisAccountVesting +} + +// GenesisAccountVesting is the vesting schedule locking part of a +// GenesisAccount's Balance. Mirrors seiv1alpha1.GenesisAccountVesting. +type GenesisAccountVesting struct { + Amount string // vesting-locked portion of Balance, coin notation; must not exceed Balance + EndTime int64 // unix timestamp the vesting schedule completes + Delayed bool // DelayedVestingAccount (all-at-once at EndTime) instead of the default ContinuousVestingAccount (linear from genesis time) } // NodeSpec is the typed input to CreateNode — one RPC node peered to a network.