Market Module 2.0 Implementation#664
Conversation
- e2e testing adjustments - bump to newer wasmd 0.54 - fix tests
- fix upgrade test - change legacy height
update upgrade height of multi test
- update go version to 1.24.7
- twap and oracle vote age checks
# Conflicts: # go.mod # go.sum # scripts/run-node-legacy.sh # tests/interchaintest/go.mod # tests/interchaintest/go.sum # wasmbinding/query_plugin.go # wasmbinding/wasm.go
- fix lint issues
There was a problem hiding this comment.
Pull request overview
This PR ships "Market Module 2.0" together with a v15 network upgrade. It restructures the swap pipeline (allowed-denom guard, oracle freshness, TWAP deviation, daily cap, epoch burn/refill), redirects a configurable share of taxes to a new market_accumulator module account, adds oracle USD-price queries with a UST meta-denom, and wires market hooks into the oracle tally. It touches consensus-critical paths across app/, x/market, x/oracle, x/tax, x/treasury, plus extensive ante/e2e test changes.
Changes:
- v15 upgrade: pin allowed swap denom to
uusd, registerUSTmeta-denom in oracle whitelist/Tobin-tax, addmarket_accumulatormodule account. - Market 2.0: in-memory allowed denoms, swap fee burn/community split, TWAP store + deviation check, daily cap, end-of-epoch burn-and-refill from accumulator, oracle freshness guard.
- Tax/treasury: new
TaxRedirectRate(default 0.6) routes a slice of taxes to the market accumulator before the existing oracle/community/burn split, with per-leg events.
Reviewed changes
Copilot reviewed 61 out of 63 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| app/app.go, app/keepers/keepers.go, app/modules.go | Register v15 upgrade, wire distribution keeper into market, add market_accumulator module account, set oracle MarketHooks |
| app/upgrades/v15/{constants,upgrades}.go | New upgrade handler: set allowed denom to uusd, ensure UST meta denom in whitelist + Tobin tax store |
| x/market/keeper/keeper.go, msg_server.go, abci.go | Allowed-denom map, freshness/TWAP/daily-cap safeguards, epoch burn+refill, swap fee split |
| x/market/types/{keys.go,params.pb.go,market.pb.go} & proto | New params (epoch length, fee splits, oracle age, TWAP window/deviation, daily-cap factor), accumulator module name, store keys |
| x/market/keeper/{epoch_test.go,safeguards_test.go,msg_server_test.go,test_utils.go} | Unit tests for new safeguards + epoch; market account perms updated in test setup |
| x/market/simulation/* | Minor genesis/param additions (does not yet cover new safeguards) |
| x/oracle/keeper/{keeper.go,querier.go,ballot.go}, types/params.go, abci.go, client/cli, proto | Add MetaUSDDenom = "UST", GetUSDPrice/IterateUSDPrices, USDPrice/USDPrices RPCs, MarketHooks tally callback |
| x/tax/keeper/tax_split.go, types/events.go, tax_split_test.go, handlers/market_msg_server.go | Market-redirect-first split with per-leg events; new Swap reverse-charge handler; focused test |
| x/treasury/{types/params.go, keeper/params.go, types/params_test.go, keeper/test_utils.go} | Add TaxRedirectRate param, getter/setter, validation, default 0.6 |
| custom/auth/ante/fee_test.go | Updated burn/tax-split test harness to account for redirect; legacy cases pin redirect to 0 |
| cmd/terrad/root.go | Removes SetBech32PrefixForConsensusNode call |
| tests/e2e/* | New end-to-end coverage for upgrade, safeguards, redirect; config knobs lowered for fast epochs |
| tests/interchaintest/{setup.go,go.sum} | Pin tax_redirect_rate to 0; regenerated go.sum with some suspicious entries |
| scripts/protocgen.sh, scripts/upgrade-test.sh | Minor script tweaks |
| proto/terra/{market,treasury,oracle}/v1beta1/*.proto + generated pb.go | Schema additions for new params and USD price RPCs |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -57,6 +72,20 @@ func (k Keeper) Logger(ctx sdk.Context) log.Logger { | |||
| return ctx.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName)) | |||
| } | |||
|
|
|||
| // SetAllowedSwapDenoms sets which denoms are allowed to be swapped with uluna. | |||
| // Note: This is intended for configuration/tests; it is not persisted. | |||
| func (k *Keeper) SetAllowedSwapDenoms(denoms []string) { | |||
| m := make(map[string]bool, len(denoms)) | |||
| for _, d := range denoms { | |||
| m[d] = true | |||
| } | |||
| k.allowedSwapDenoms = m | |||
| } | |||
|
|
|||
| func (k Keeper) isAllowedSwapDenom(denom string) bool { | |||
| return k.allowedSwapDenoms[denom] | |||
| } | |||
| // GetTWAPPrices returns the recent price snapshots for a denom | ||
| func (k Keeper) GetTWAPPrices(ctx sdk.Context, denom string) []PriceSnapshot { | ||
| store := ctx.KVStore(k.storeKey) | ||
| bz := store.Get(types.GetTWAPPriceKey(denom)) | ||
| if bz == nil { | ||
| return []PriceSnapshot{} | ||
| } | ||
|
|
||
| var snapshots []PriceSnapshot | ||
| // Encoding: each snapshot is height (8 bytes) + price length (4 bytes) + price (variable) | ||
| offset := 0 | ||
| for offset < len(bz) { | ||
| if offset+8 > len(bz) { | ||
| break | ||
| } | ||
| height := int64(sdk.BigEndianToUint64(bz[offset : offset+8])) | ||
| offset += 8 | ||
|
|
||
| // Read price length (4 bytes) | ||
| if offset+4 > len(bz) { | ||
| break | ||
| } | ||
| priceLen := int(uint32(bz[offset])<<24 | uint32(bz[offset+1])<<16 | uint32(bz[offset+2])<<8 | uint32(bz[offset+3])) | ||
| offset += 4 | ||
|
|
||
| // Read price bytes | ||
| if offset+priceLen > len(bz) { | ||
| break | ||
| } | ||
| priceBytes := bz[offset : offset+priceLen] | ||
| offset += priceLen | ||
|
|
||
| var dp sdk.DecProto | ||
| if err := k.cdc.Unmarshal(priceBytes, &dp); err == nil { | ||
| snapshots = append(snapshots, PriceSnapshot{Height: height, Price: dp.Dec}) | ||
| } | ||
| } | ||
| return snapshots | ||
| } | ||
|
|
||
| // AddTWAPPrice adds a new price snapshot and prunes old ones | ||
| func (k Keeper) AddTWAPPrice(ctx sdk.Context, denom string, price math.LegacyDec) { | ||
| snapshots := k.GetTWAPPrices(ctx, denom) | ||
| currentHeight := ctx.BlockHeight() | ||
| lookback := int64(k.TwapLookbackWindow(ctx)) | ||
|
|
||
| // Add new snapshot | ||
| snapshots = append(snapshots, PriceSnapshot{Height: currentHeight, Price: price}) | ||
|
|
||
| // Prune old snapshots (keep only those within lookback window) | ||
| pruned := []PriceSnapshot{} | ||
| for _, snap := range snapshots { | ||
| if currentHeight-snap.Height <= lookback { | ||
| pruned = append(pruned, snap) | ||
| } | ||
| } | ||
|
|
||
| // Encode and store | ||
| store := ctx.KVStore(k.storeKey) | ||
| var bz []byte | ||
| for _, snap := range pruned { | ||
| heightBytes := sdk.Uint64ToBigEndian(uint64(snap.Height)) | ||
| priceBytes := k.cdc.MustMarshal(&sdk.DecProto{Dec: snap.Price}) | ||
| // Store length + data for variable-length protobuf (4 bytes for length) | ||
| priceLen := uint32(len(priceBytes)) | ||
| lengthBytes := []byte{ | ||
| byte(priceLen >> 24), | ||
| byte(priceLen >> 16), | ||
| byte(priceLen >> 8), | ||
| byte(priceLen), | ||
| } | ||
| bz = append(bz, heightBytes...) | ||
| bz = append(bz, lengthBytes...) | ||
| bz = append(bz, priceBytes...) | ||
| } | ||
|
|
||
| if len(bz) > 0 { | ||
| store.Set(types.GetTWAPPriceKey(denom), bz) | ||
| } else { | ||
| store.Delete(types.GetTWAPPriceKey(denom)) | ||
| } | ||
| } |
| // Swap handles MsgSwap with tax deduction | ||
| func (s *MarketMsgServer) Swap(ctx context.Context, msg *markettypes.MsgSwap) (*markettypes.MsgSwapResponse, error) { | ||
| sdkCtx := sdk.UnwrapSDKContext(ctx) | ||
|
|
||
| if !s.taxKeeper.IsReverseCharge(sdkCtx, true) { | ||
| return s.messageServer.Swap(ctx, msg) | ||
| } | ||
|
|
||
| sender := sdk.MustAccAddressFromBech32(msg.Trader) | ||
| netOfferCoin, err := s.taxKeeper.DeductTax(sdkCtx, sender, sdk.NewCoins(msg.OfferCoin), false) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| msg.OfferCoin = netOfferCoin[0] | ||
|
|
||
| return s.messageServer.Swap(ctx, msg) | ||
| } |
| var epochLengthBlocks uint64 | ||
| simState.AppParams.GetOrGenerate( | ||
| string(types.KeyEpochLengthBlocks), &epochLengthBlocks, simState.Rand, | ||
| func(r *rand.Rand) { epochLengthBlocks = GenEpochLengthBlocks(r) }, | ||
| ) |
| // Number of blocks per epoch for market burn/refill. Default: 30 days worth of blocks. | ||
| uint64 epoch_length_blocks = 4 [(gogoproto.moretags) = "yaml:\"epoch_length_blocks\""]; | ||
|
|
||
| // Fraction of swap fee to burn [0,1] | ||
| bytes swap_fee_burn_rate = 5 [ | ||
| (cosmos_proto.scalar) = "cosmos.Dec", | ||
| (gogoproto.moretags) = "yaml:\"swap_fee_burn_rate\"", | ||
| (gogoproto.customtype) = "cosmossdk.io/math.LegacyDec", | ||
| (gogoproto.nullable) = false | ||
| ]; | ||
|
|
||
| // Fraction of swap fee to send to Community Pool [0,1] | ||
| bytes swap_fee_community_rate = 6 [ | ||
| (cosmos_proto.scalar) = "cosmos.Dec", | ||
| (gogoproto.moretags) = "yaml:\"swap_fee_community_rate\"", | ||
| (gogoproto.customtype) = "cosmossdk.io/math.LegacyDec", | ||
| (gogoproto.nullable) = false | ||
| ]; | ||
|
|
||
| // Maximum age in seconds for oracle prices before swaps are denied. Default: 75 seconds (25 blocks * 3s) | ||
| uint64 max_oracle_age_seconds = 7 [(gogoproto.moretags) = "yaml:\"max_oracle_age_seconds\""]; | ||
|
|
||
| // Number of blocks for TWAP calculation window. Default: 45 blocks | ||
| uint64 twap_lookback_window = 8 [(gogoproto.moretags) = "yaml:\"twap_lookback_window\""]; | ||
|
|
||
| // Maximum deviation from TWAP before swap is rejected [0,1]. Default: 0.10 (10%) | ||
| bytes max_twap_deviation = 9 [ | ||
| (cosmos_proto.scalar) = "cosmos.Dec", | ||
| (gogoproto.moretags) = "yaml:\"max_twap_deviation\"", | ||
| (gogoproto.customtype) = "cosmossdk.io/math.LegacyDec", | ||
| (gogoproto.nullable) = false | ||
| ]; | ||
|
|
||
| // Daily cap factor: fraction of pool balance usable per day [0,1]. Default: 0.10 (10%) | ||
| bytes daily_cap_factor = 10 [ | ||
| (cosmos_proto.scalar) = "cosmos.Dec", | ||
| (gogoproto.moretags) = "yaml:\"daily_cap_factor\"", | ||
| (gogoproto.customtype) = "cosmossdk.io/math.LegacyDec", | ||
| (gogoproto.nullable) = false | ||
| ]; |
|
|
||
| // Initialize/ensure allowed swap denoms for market: restrict to uusd by default. | ||
| k.MarketKeeper.SetAllowedSwapDenoms([]string{"uusd"}) | ||
|
|
There was a problem hiding this comment.
should initialized TaxRedirectRate k.TreasuryKeeper.SetTaxRedirectRate(sdkCtx, sdkmath.LegacyZeroDec()),
| oracletypes.ModuleName: true, | ||
| treasurytypes.BurnModuleName: true, | ||
| markettypes.ModuleName: true, | ||
| markettypes.AccumulatorModuleName: true, |
There was a problem hiding this comment.
maccPerms registers AccumulatorModuleName for new chains (via InitChainer), but for an existing chain upgrading, the account doesn't exist in the auth store. The upgrade handler must create it
| DefaultBurnTaxSplit = sdkmath.LegacyNewDecWithPrec(1, 1) // 10% goes to community pool, 90% burn | ||
| DefaultMinInitialDepositRatio = sdkmath.LegacyZeroDec() // 0% min initial deposit | ||
| DefaultOracleSplit = sdkmath.LegacyOneDec() // 100% oracle, community tax (CP) is deducted before oracle split | ||
| DefaultTaxRedirectRate = sdkmath.LegacyNewDecWithPrec(6, 1) // 0.6 redirected to market accumulator (pre-oracle-split) |
There was a problem hiding this comment.
default 60% is ok, right?
Summary of changes (Copilot)
This pull request introduces the v15 network upgrade and implements a new tax redirect mechanism for the market module, along with associated test coverage and module/account configuration changes. The upgrade restricts allowed swap denoms to
uusdby default, ensures the oracle meta denom is present, and refactors the burn/tax split logic to support redirecting a portion of taxes to the market accumulator. Test logic is updated to cover these new behaviors.Network Upgrade and Module Changes:
uusdand ensures the oracle meta denom is included in vote targets. The upgrade is registered in the app and includes no store migrations. (app/upgrades/v15/constants.go,app/upgrades/v15/upgrades.go,app/app.go) [1] [2] [3] [4]app/keepers/keepers.go)Tax Redirect and Module Account Handling:
app/modules.go) [1] [2]custom/auth/ante/fee_test.go) [1] [2] [3] [4] [5]Test Improvements:
custom/auth/ante/fee_test.go) [1] [2] [3]Minor/Housekeeping:
app/app.go,app/modules.go) [1] [2] [3]cmd/terrad/root.go)