Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions gno.land/pkg/gnoland/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,28 @@ func decodeSmallField(ref *GenesisStateRef, key string, into any) error {
}

func (cfg InitChainerConfig) applyBalance(ctx sdk.Context, bal Balance) {
acc := cfg.acck.NewAccountWithAddress(ctx, bal.Address)
cfg.acck.SetAccount(ctx, acc)
if bal.IsVesting() {
baseAcc := std.BaseAccount{
Address: bal.Address,
Coins: bal.Amount,
AccountNumber: cfg.acck.GetNextAccountNumber(ctx),
}
var acc std.Account
var err error
switch bal.Vesting.Type {
case std.VestingDelayed:
acc, err = std.NewDelayedVestingAccount(&baseAcc, *bal.Vesting)
default: // VestingContinuous (empty string) — linear vesting
acc, err = std.NewContinuousVestingAccount(&baseAcc, *bal.Vesting)
}
if err != nil {
panic(fmt.Errorf("invalid vesting account for %s: %w", bal.Address, err))
}
cfg.acck.SetAccount(ctx, acc)
Comment thread
julienrbrt marked this conversation as resolved.
} else {
acc := cfg.acck.NewAccountWithAddress(ctx, bal.Address)
cfg.acck.SetAccount(ctx, acc)
}
if err := cfg.bankk.SetCoins(ctx, bal.Address, bal.Amount); err != nil {
panic(err)
}
Expand Down
98 changes: 98 additions & 0 deletions gno.land/pkg/gnoland/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3131,6 +3131,104 @@ func TestInitChainer_StreamingAppState_TxParity(t *testing.T) {
"in-memory vs streaming tx outcomes must match")
}

func TestInitChainer_VestingAccount(t *testing.T) {
t.Parallel()

key := getDummyKey(t)
addr := key.PubKey().Address()
chainID := "test"

vestingAmount := std.NewCoins(std.NewCoin("ugnot", 500_000))
totalBalance := std.NewCoins(std.NewCoin("ugnot", 1_000_000))

tests := []struct {
name string
vesting *std.VestingSchedule
isVesting bool
}{
{
"continuous vesting",
&std.VestingSchedule{
OriginalVesting: vestingAmount,
StartTime: 100,
EndTime: 200,
},
true,
},
{
"delayed vesting",
&std.VestingSchedule{
OriginalVesting: vestingAmount,
StartTime: 0,
EndTime: 200,
Type: std.VestingDelayed,
},
true,
},
{
"no vesting",
nil,
false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

testDb := memdb.NewMemDB()
testApp, err := NewAppWithOptions(TestAppOptions(testDb))
require.NoError(t, err)

state := DefaultGenState()
state.Balances = []Balance{
{
Address: addr,
Amount: totalBalance,
Vesting: tt.vesting,
},
}

resp := testApp.InitChain(abci.RequestInitChain{
ChainID: chainID,
Time: time.Unix(150, 0), // halfway through vesting
ConsensusParams: &abci.ConsensusParams{
Block: defaultBlockParams(),
Validator: &abci.ValidatorParams{
PubKeyTypeURLs: []string{},
},
},
AppState: state,
})
require.True(t, resp.IsOK(), "InitChain response: %v", resp)

// Commit to persist the genesis state before querying.
cres := testApp.Commit()
require.NotNil(t, cres)

// Query the account to verify it exists.
qres := testApp.Query(abci.RequestQuery{
Path: fmt.Sprintf("auth/accounts/%s", addr),
})
require.True(t, qres.IsOK(), "account query response: %v", qres)

if tt.isVesting {
// The account should be a vesting account type.
assert.Contains(t, string(qres.Data), "Vesting")
// The account number must be present.
assert.Contains(t, string(qres.Data), "account_number")
}

// Verify the coins are set correctly.
qresBank := testApp.Query(abci.RequestQuery{
Path: fmt.Sprintf("bank/balances/%s", addr),
})
require.True(t, qresBank.IsOK(), "bank query response: %v", qresBank)
assert.Contains(t, string(qresBank.Data), "ugnot")
})
}
}

// writeMinimalGenesisFile emits a tm2.GenesisDoc-shaped JSON file under
// t.TempDir() that wraps the given GnoGenesisState as `app_state`. Uses
// the same SaveAs serialization the production gnogenesis CLI uses, so
Expand Down
95 changes: 86 additions & 9 deletions gno.land/pkg/gnoland/balance.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ import (
"fmt"
"io"
"slices"
"strconv"
"strings"

bft "github.com/gnolang/gno/tm2/pkg/bft/types"
"github.com/gnolang/gno/tm2/pkg/crypto"
"github.com/gnolang/gno/tm2/pkg/std"
)

// Balance represents a genesis account balance with an optional vesting schedule.
type Balance struct {
Address bft.Address
Amount std.Coins
Address bft.Address `json:"address" yaml:"address"`
Amount std.Coins `json:"amount" yaml:"amount"`
Vesting *std.VestingSchedule `json:"vesting,omitempty" yaml:"vesting,omitempty"`
}

// IsVesting returns true if this balance entry creates a vesting account.
func (b Balance) IsVesting() bool {
return b.Vesting != nil && !b.Vesting.IsZero()
}

func (b *Balance) Verify() error {
Expand All @@ -26,25 +34,83 @@ func (b *Balance) Verify() error {
return ErrBalanceEmptyAmount
}

if b.Vesting != nil {
if err := b.Vesting.Validate(); err != nil {
return fmt.Errorf("invalid vesting schedule: %w", err)
}
if !b.Amount.IsAllGTE(b.Vesting.OriginalVesting) {
return fmt.Errorf(
"original vesting amount (%s) exceeds total balance (%s)",
b.Vesting.OriginalVesting, b.Amount,
)
}
}

return nil
}

func (b *Balance) Parse(entry string) error {
parts := strings.Split(strings.TrimSpace(entry), "=") // <address>=<coins>
if len(parts) != 2 {
// Format: <address>=<coins> [;vesting=<coins>,<start>,<end> [;type=delayed]]
// The vesting suffix is optional.
parts := strings.SplitN(strings.TrimSpace(entry), ";", 3)
balancePart := parts[0]

kv := strings.SplitN(balancePart, "=", 2)
if len(kv) != 2 {
return fmt.Errorf("malformed entry: %q", entry)
}

var err error

b.Address, err = crypto.AddressFromBech32(parts[0])
b.Address, err = crypto.AddressFromBech32(kv[0])
if err != nil {
return fmt.Errorf("invalid address %q: %w", parts[0], err)
return fmt.Errorf("invalid address %q: %w", kv[0], err)
}

b.Amount, err = std.ParseCoins(parts[1])
b.Amount, err = std.ParseCoins(kv[1])
if err != nil {
return fmt.Errorf("invalid amount %q: %w", parts[1], err)
return fmt.Errorf("invalid amount %q: %w", kv[1], err)
}

// Parse optional vesting suffix.
if len(parts) >= 2 {
vestingPart := parts[1]
if !strings.HasPrefix(vestingPart, "vesting=") {
return fmt.Errorf("malformed vesting option: %q", vestingPart)
}
vestingValue := strings.TrimPrefix(vestingPart, "vesting=")

// vesting=<coins>,<start_time>,<end_time>
fields := strings.SplitN(vestingValue, ",", 3)
if len(fields) != 3 {
return fmt.Errorf("malformed vesting schedule: expected <coins>,<start>,<end>, got %q", vestingValue)
}

var schedule std.VestingSchedule
schedule.OriginalVesting, err = std.ParseCoins(fields[0])
if err != nil {
return fmt.Errorf("invalid vesting amount %q: %w", fields[0], err)
}
schedule.StartTime, err = strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return fmt.Errorf("invalid vesting start time %q: %w", fields[1], err)
}
schedule.EndTime, err = strconv.ParseInt(fields[2], 10, 64)
if err != nil {
return fmt.Errorf("invalid vesting end time %q: %w", fields[2], err)
}

// Parse optional type discriminator.
if len(parts) == 3 {
typePart := parts[2]
if typePart == "type=delayed" {
schedule.Type = std.VestingDelayed
} else {
return fmt.Errorf("unknown vesting type: %q", typePart)
}
}

b.Vesting = &schedule
}

return nil
Expand All @@ -59,7 +125,18 @@ func (b Balance) MarshalAmino() (string, error) {
}

func (b Balance) String() string {
return fmt.Sprintf("%s=%s", b.Address.String(), b.Amount.String())
s := fmt.Sprintf("%s=%s", b.Address.String(), b.Amount.String())
if b.Vesting != nil && !b.Vesting.IsZero() {
s += fmt.Sprintf(";vesting=%s,%d,%d",
b.Vesting.OriginalVesting.String(),
b.Vesting.StartTime,
b.Vesting.EndTime,
)
if b.Vesting.Type == std.VestingDelayed {
s += ";type=delayed"
}
}
return s
}

type Balances map[crypto.Address]Balance
Expand Down
Loading
Loading