classic-core icon indicating copy to clipboard operation
classic-core copied to clipboard

TIP #46 Seigniorage Redirection

Open yun-yeo opened this issue 2 years ago • 0 comments

TIP #: 46

Author: TFL Network: v0.6.x Date:

Summary

Create a mechanism to divert seigniorage to different addresses, whitelisted through government votes. The amount of seigniorage sent to each address will be weighted. Seigniorage will be captured from burned Luna during swaps from Luna to Terra stablecoins.

Motivation

As the Terra protocol moves to direct seigniorage to various use cases such as building Bitcoin reserves, the protocol needs to be able to direct seigniorage to various wallets, as decided by governance.

Tech Spec

Modules:

  • Market module: add new seigniorage logic
  • Treasury: move seigniorage to market

Overview

Allow a gov-maintained whitelist of addresses and their respective weights, whereby some portion of the Luna offered by the trader to the market module to mint stablecoin is sent to the whitelisted addresses in proportion to their respective weights, while the rest is burned.

Method

In the market module, create the following data structures in the Keeper store to keep track of addresses to which seigniorage will be routed, and each route’s weights. Also implement SeigniorageRouteChange proposal to make them modifiable via governance vote.

type SeigniorageRoute struct {
	Address string                                 `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty" yaml:"address"`
	Weight  github_com_cosmos_cosmos_sdk_types.Dec `protobuf:"bytes,2,opt,name=weight,proto3,customtype=github.com/cosmos/cosmos-sdk/types.Dec" json:"weight" yaml:"weight"`
}

type SeigniorageRoutes struct {
	Routes []SeigniorageRoute `protobuf:"bytes,1,rep,name=routes,proto3" json:"routes"`
}
type SeigniorageRouteChangeProposal struct {
	Title       string             `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
	Description string             `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
	Routes      []SeigniorageRoute `protobuf:"bytes,3,rep,name=routes,proto3" json:"routes"`
}

When a MsgSwap is received by the Market module handler, offered coins for the swap should be routed to the addresses in the seigniorageRoutes list, weighted by seigniorageRouteWeights. The remainder of the offered coins should be burned.

The route operation and burn operation are performed once per block in the EndBlocker to reduce swap operation cost.

Initial state of these data structures:

// TODO - fix the initial seigniorage routes
seigniorageRoutes := []types.SeigniorageRoute {
		{
			Address: types.AlternateCommunityPoolAddress,
			Weight: sdk.NewDecWithPrec(2, 1), // 20%
		},
		{
			Address: authtypes.NewModuleAddress(authtypes.FeeCollectorName).String(),
			Weight: sdk.NewDecWithPrec(1, 1), // 10%
		},
	}

Invariants

  • SeigniorageRouteWeights sum of weights should be less than 1.
  • All values in SeigniorageRouteWeights should have positive value.
// ValidateRoutes validate each routes and following invariants
// - sum of weights must be smaller than 1
// - all weights should have positive value
func (s SeigniorageRoutes) ValidateRoutes() error {
	routes := s.Routes

	weightsSum := sdk.ZeroDec()
	addrMap := map[string]bool{}
	for _, pc := range routes {
		_, err := sdk.AccAddressFromBech32(pc.Address)
		if err != nil {
			return ErrInvalidAddress
		}

		// each weight must be bigger than zero
		if !pc.Weight.IsPositive() {
			return ErrInvalidWeight
		}

		// check duplicated address
		if _, exists := addrMap[pc.Address]; exists {
			return ErrDuplicateRoute
		}

		weightsSum = weightsSum.Add(pc.Weight)
		addrMap[pc.Address] = true
	}

	// the sum of weights must be smaller than one
	if weightsSum.GTE(sdk.OneDec()) {
		return ErrInvalidWeightsSum
	}

	return nil
}

Code

// abci.go

// EndBlocker is called at the end of every block
func EndBlocker(ctx sdk.Context, k keeper.Keeper) {
...

	// Settle seigniorage to the registered recipients
	// and burn all left coins
	k.SettleSeigniorage(ctx)

...
}
// seigniorage.go

// SettleSeigniorage settle seigniorage to the registered addresses
// and burn left coins. The recipient addresses can be registered
// via SeigniorageRouteChangeProposal.
func (k Keeper) SettleSeigniorage(ctx sdk.Context) {
	moduleAddr := k.AccountKeeper.GetModuleAddress(types.ModuleName)
	collectedCoins := k.BankKeeper.GetAllBalances(ctx, moduleAddr)

	// no coins, then no actions are required
	if collectedCoins.Empty() {
		return
	}

	// only Luna will be distributed as seigniorage 
	seigniorageAmount := collectedCoins.AmountOf(core.MicroLunaDenom)
	routes := k.GetSeigniorageRoutes(ctx)

	var burnCoins sdk.Coins

	// If seigniorageAmount is zero, then just burn all collected coins 
	if !seigniorageAmount.IsZero() {
		leftSeigniorageAmount := sdk.NewInt(seigniorageAmount.Int64())
		for _, route := range routes {
			routeAmount := route.Weight.MulInt(seigniorageAmount).TruncateInt()
			if routeAmount.IsPositive() {
				coins := sdk.NewCoins(sdk.NewCoin(core.MicroLunaDenom, routeAmount))
				recipient, err := sdk.AccAddressFromBech32(route.Address)
				if err != nil {
					panic(err)
				}
	
				// transfer weight * seigniorage amount LUNA token to the recipient address
				if route.Address == types.AlternateCommunityPoolAddress.String() {
					// If the given address is the predefined alternate address, 
					// fund community pool because community pool does not have 
					// its own address.
					// - https://github.com/cosmos/cosmos-sdk/issues/10811
					err = k.DistributionKeeper.FundCommunityPool(ctx, coins, moduleAddr)
				} else {
					err = k.BankKeeper.SendCoins(ctx, moduleAddr, recipient, coins)
				}
				if err != nil {
					panic(err)
				}
	
				leftSeigniorageAmount = leftSeigniorageAmount.Sub(routeAmount)
			}
		}

		for _, coin := range collectedCoins {
			// replace Luna amount to burn amount
			if coin.Denom == core.MicroLunaDenom {
				coin.Amount = leftSeigniorageAmount
			}
	
			if coin.Amount.IsPositive() {
				burnCoins = append(burnCoins, coin)
			}
		}
	} else {
		burnCoins = collectedCoins
	}
	
	// burn all left coins
	if !burnCoins.Empty() {
		err := k.BankKeeper.BurnCoins(ctx, types.ModuleName, burnCoins)
		if err != nil {
			panic(err)
		}
	}

	return
}
// keys.go

// AlternateCommunityPoolAddress is intended to replace the community pool.
// Since the community pool does not have its own address, an alternate
// address is needed to register as a seigniorage route.
// - https://github.com/cosmos/cosmos-sdk/issues/10811
//
// The bech32 encoded form of the alternate address is
// terra1pf89qgchfytxmd0fvdn3zfdshja4fqtevepu93    
AlternateCommunityPoolAddress = authtypes.NewModuleAddress("community_pool_placeholder")

Considerations

NA

Timeline

NA

Test cases

  1. TestSettleSeigniorage_ZeroBalance - can handle zero balance
func TestSettleSeigniorage_ZeroBalance(t *testing.T) {
	input := CreateTestInput(t)

	require.NotPanics(t, func() {
		input.MarketKeeper.SettleSeigniorage(input.Ctx)
	})
}
  1. TestSettleSeigniorage_ZeroSeigniorage - can handle zero seigniorage
func TestSettleSeigniorage_ZeroSeigniorage(t *testing.T) {
	input := CreateTestInput(t)

	moduleAddr := authtypes.NewModuleAddress(types.ModuleName)
	coins := sdk.NewCoins(
		sdk.NewCoin(core.MicroUSDDenom, sdk.NewInt(1000000)),
		sdk.NewCoin(core.MicroKRWDenom, sdk.NewInt(1000000)),
	)

	err := FundAccount(input, moduleAddr, coins)
	require.NoError(t, err)

	require.NotPanics(t, func() {
		input.MarketKeeper.SettleSeigniorage(input.Ctx)
	})

	// Check whether the coins are burned
	checkBalance(t, input, moduleAddr, sdk.NewCoins())
}
  1. TestSettleSeigniorage_OnlySeigniorage - can handle seigniorage redirection when market module only has Luna
func TestSettleSeignirage_OnlySeigniorage(t *testing.T) {
	input := CreateTestInput(t)

	moduleAddr := authtypes.NewModuleAddress(types.ModuleName)
	coins := sdk.NewCoins(
		sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(1000000)),
	)

	err := FundAccount(input, moduleAddr, coins)
	require.NoError(t, err)

	// Set seigniorage routes
	feeCollectorAddr := authtypes.NewModuleAddress(authtypes.FeeCollectorName)
	input.MarketKeeper.SetSeigniorageRoutes(input.Ctx, []types.SeigniorageRoute{
		{
			Address: types.AlternateCommunityPoolAddress.String(),
			Weight:  sdk.NewDecWithPrec(2, 1), // 20% to community pool
		},
		{
			Address: feeCollectorAddr.String(),
			Weight:  sdk.NewDecWithPrec(1, 1), // 10% to fee collector
		},
	})

	require.NotPanics(t, func() {
		input.MarketKeeper.SettleSeigniorage(input.Ctx)
	})

	// Check whether the coins are redirected
	// and left coins are burned
	checkBalance(t, input, moduleAddr, sdk.NewCoins())
	checkBalance(t, input, feeCollectorAddr, sdk.NewCoins(sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(100000))))
	checkCommunityPoolBalance(t, input, sdk.NewCoins(sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(200000))))
}
  1. TestSettleSeigniorage_WithSeigniorage - can handle seigniorage redirection when market module has various coins with Luna
func TestSettleSeigniorage_WithSeigniorage(t *testing.T) {
	input := CreateTestInput(t)

	moduleAddr := authtypes.NewModuleAddress(types.ModuleName)
	coins := sdk.NewCoins(
		sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(1000000)),
		sdk.NewCoin(core.MicroUSDDenom, sdk.NewInt(1000000)),
		sdk.NewCoin(core.MicroKRWDenom, sdk.NewInt(1000000)),
	)

	err := FundAccount(input, moduleAddr, coins)
	require.NoError(t, err)

	// Set seigniorage routes
	feeCollectorAddr := authtypes.NewModuleAddress(authtypes.FeeCollectorName)
	input.MarketKeeper.SetSeigniorageRoutes(input.Ctx, []types.SeigniorageRoute{
		{
			Address: types.AlternateCommunityPoolAddress.String(),
			Weight:  sdk.NewDecWithPrec(2, 1), // 20% to community pool
		},
		{
			Address: feeCollectorAddr.String(),
			Weight:  sdk.NewDecWithPrec(1, 1), // 10% to fee collector
		},
	})

	require.NotPanics(t, func() {
		input.MarketKeeper.SettleSeigniorage(input.Ctx)
	})

	// Check whether the coins are redirected
	// and left coins are burned
	checkBalance(t, input, moduleAddr, sdk.NewCoins())
	checkBalance(t, input, feeCollectorAddr, sdk.NewCoins(sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(100000))))
	checkCommunityPoolBalance(t, input, sdk.NewCoins(sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(200000))))
}
  1. TestSwapMsg_OfferCoinLeftInModuleAccount - offer coin should left after swap
func TestSwapMsg_OfferCoinLeftInModuleAccount(t *testing.T) {
	input, msgServer := setup(t)

	offerCoin := sdk.NewCoin(core.MicroLunaDenom, sdk.NewInt(1000000))
	swapMsg := types.NewMsgSwap(addr, offerCoin, core.MicroSDRDenom)
	_, err := msgServer.Swap(ctx, swapMsg)
	require.NoError(t, err)

	checkBalance(t, input, moduleAddr, sdk.NewCoins(offerCoin))
}
  1. TestSeigniorageRoutes_ValidateRoutes - each route must have non-empty address and the weight must be positive. Also the sum of weights must be smaller than 1.
func TestSeigniorageRoutes_ValidateRoutes(t *testing.T) {
	testCases := []struct {
		name   string
		routes SeigniorageRoutes
		expErr error
	}{
		{
			"empty address",
			SeigniorageRoutes {
				{
					Address: "",
					Weight:  sdk.NewDecWithPrec(1, 1),
				}
			},
			ErrInvalidAddress,
		},
		{
			"invalid address",
			SeigniorageRoutes {
				{
					Address: "osmosis1sznj93ytuxwxh27ufk0amx547p3jr374c63zzm",
					Weight:  sdk.NewDecWithPrec(1, 1),
				}
			},
			ErrInvalidAddress,
		},
		{
			"duplicate addresses",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.NewDecWithPrec(1, 1),
				},
				{
					Address: addr1,
					Weight:  sdk.NewDecWithPrec(2, 1),
				}
			},
			ErrDuplicateRoute,	
		}
		{
			"negative weight",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.NewDecWithPrec(-1, 1),
				}
			},
			ErrInvalidWeight,
		},
		{
			"zero weight",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.ZeroDec(),
				}
			},
			ErrInvalidWeight,
		},
		{
			"one weight",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.OneDec(),
				}
			},
			ErrInvalidWeightsSum,
		},
		{
			"weight sum exceeding one",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.NewDecWithPrec(5, 1),
				},
				{
					Address: addr2,
					Weight:  sdk.NewDecWithPrec(5, 1),
				}
			},
			ErrInvalidWeightsSum,
		},
		{
			"valid routes",
			SeigniorageRoutes {
				{
					Address: addr1,
					Weight:  sdk.NewDecWithPrec(1, 1),
				},
				{
					Address: addr2,
					Weight:  sdk.NewDecWithPrec(2, 1),
				}
			},
			nil,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			err := routes.ValidateRoutes()
			if tc.expErr != nil {
				require.ErrorIs(t, err, tc.expErr)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

yun-yeo avatar Apr 14 '22 07:04 yun-yeo