classic-core
classic-core copied to clipboard
TIP #46 Seigniorage Redirection
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
- TestSettleSeigniorage_ZeroBalance - can handle zero balance
func TestSettleSeigniorage_ZeroBalance(t *testing.T) {
input := CreateTestInput(t)
require.NotPanics(t, func() {
input.MarketKeeper.SettleSeigniorage(input.Ctx)
})
}
- 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())
}
- 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))))
}
- 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))))
}
- 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))
}
- 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)
}
})
}
}