Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions app/provider/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,12 +392,14 @@ func New(
app.SlashingKeeper,
app.AccountKeeper,
app.BankKeeper,
app.DistrKeeper,
govkeeper.Keeper{}, // will be set after the GovKeeper is created
authtypes.NewModuleAddress(govtypes.ModuleName).String(),
authcodec.NewBech32Codec(sdk.GetConfig().GetBech32ValidatorAddrPrefix()),
authcodec.NewBech32Codec(sdk.GetConfig().GetBech32ConsensusAddrPrefix()),
authtypes.FeeCollectorName,
)
app.BankKeeper.AppendSendRestriction(app.ProviderKeeper.FeePoolSendRestriction())

govConfig := govtypes.DefaultConfig()
app.GovKeeper = govkeeper.NewKeeper(
Expand Down
120 changes: 120 additions & 0 deletions docs/consumer-fee-pool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Consumer Fee Pool

Every consumer chain on VAAS has a dedicated fee pool on the provider chain,
held at a deterministic account address derived from the consumer ID:

fee_pool_address = NewModuleAddress("vaas-consumer-fee-pool-<consumer_id>")

This account funds the per-block service charge that the provider drains
from the pool every block (`fees_per_block`) while the consumer is in
`CONSUMER_PHASE_LAUNCHED`. If the pool is short, the consumer is flagged as
in-debt and its ante gate blocks user transactions until funding is restored.

## Funding

Funding the pool MUST go through `MsgFundConsumerFeePool`. Direct bank sends
to the fee pool address are rejected by a `bank.SendRestriction` registered
on the provider chain — funds sent that way will either bounce (IBC) or fail
the transaction (direct `MsgSend`). This restriction exists so the
share-accounting (see below) never gets out of sync with the actual pool
balance.

`MsgFundConsumerFeePool` accepts a single `Coin` whose denom must match the
current `fees_per_block.Denom`. Anyone may sign. The signer is credited with
shares.

### Cross-chain funding via ICA

To fund a pool from another chain, register an Interchain Account on the
provider, IBC-transfer funds into the ICA's account, and have the controller
side send a `MsgFundConsumerFeePool` from the ICA. The ICA becomes the
depositor of record.

A direct IBC transfer addressed to a fee pool fails losslessly: the bank
send-restriction rejects the receive on the provider, the packet acks with an
error, and the source-chain transfer module refunds the sender via standard
IBC semantics. The funds are not lost, just not deposited.

### Funding from the community pool

A governance proposal containing `MsgFundConsumerFeePool` with the gov
module authority as `signer` will pull funds from the cosmos-sdk
distribution community pool and credit the distribution module account as
the depositor.

## Withdrawing

Each depositor controls their own shares and can withdraw at any time via
`MsgWithdrawConsumerFeePool`. The message accepts multi-denom `Coins` and
is atomic — if any denom in the request fails its share check, the whole
transaction reverts.

### Share math (TL;DR)

- Shares are minted when you deposit. Initial deposit mints
`shares = amount`; subsequent deposits mint
`amount × total_shares / pool_balance` (balance BEFORE this deposit).
- Your claim at any time is
`your_shares × pool_balance / total_shares`.
- A withdraw of `amount ≥ claim` burns all your shares and delivers your
exact claim. Partial withdraws (`amount < claim`) burn proportional
shares and may deliver marginally less than requested due to integer
truncation.

This is the same accounting pattern used by ERC-4626 vaults and liquid
staking modules: per-block fee consumption reduces share value, not share
count, so consumption is borne pro-rata by current share-holders.

## Sweeping

The consumer owner can trigger a full settlement via
`MsgSweepConsumerFeePool`, distributing the pool pro-rata to all
share-holders. The message takes an optional list of denoms; if empty, all
denoms with shares or balance are swept. Any truncation residue per denom
is forwarded to the community pool.

The same sweep runs automatically when a consumer is deleted (auto-sweep
on `DeleteConsumerChain`). If the auto-sweep fails for any reason, the
delete aborts and the consumer stays in `STOPPED` — funds are never
silently lost.

## Trust model

- Producer governance has **no** unilateral authority over consumer-owned
funds. Gov interacts as a single depositor (via the community pool path)
using the same messages as everyone else.
- The consumer owner can trigger settlement but cannot redirect funds to
arbitrary recipients — pro-rata distribution to known depositors is the
only outcome.
- Each depositor controls their own shares independently.

## Queries

- `vaas query consumer-fee-pool-claim <consumer-id> <depositor>` — one
depositor's claim across all denoms. Pass the gov authority address to
query the community pool's holdings (the query aliases the gov authority
to the distribution module account, which is the depositor of record for
community-pool funding).
- `vaas query consumer-fee-pool-claims <consumer-id>` — paginated list of
all depositors with non-zero claims.

## CLI examples

# fund a pool with 1000uphoton from your key
vaas tx fund-consumer-fee-pool 5 1000uphoton --from operator

# withdraw a mix of denoms from your share in pool 5
vaas tx withdraw-consumer-fee-pool 5 250uphoton,30uatone --from operator

# owner sweeps all denoms with shares or balance
vaas tx sweep-consumer-fee-pool 5 --from owner

# owner sweeps only the listed denoms (comma-separated or repeated flag)
vaas tx sweep-consumer-fee-pool 5 --denoms=uphoton,uatone --from owner
vaas tx sweep-consumer-fee-pool 5 --denoms=uphoton --denoms=uatone --from owner

# query a single depositor's claim
vaas query consumer-fee-pool-claim 5 cosmos1...

# paginated list of all depositors with non-zero claims
vaas query consumer-fee-pool-claims 5 --page 1 --limit 100
18 changes: 18 additions & 0 deletions proto/vaas/provider/v1/genesis.proto
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ option go_package = "github.com/allinbits/vaas/x/vaas/provider/types";

import "gogoproto/gogo.proto";
import "google/protobuf/timestamp.proto";
import "cosmos_proto/cosmos.proto";
import "vaas/v1/shared_consumer.proto";
import "vaas/v1/wire.proto";
import "vaas/provider/v1/provider.proto";
Expand Down Expand Up @@ -33,6 +34,9 @@ message GenesisState {
// empty for a new chain
repeated ConsumerAddrsToPrune consumer_addrs_to_prune = 7
[ (gogoproto.nullable) = false ];
// empty for a new chain
repeated ConsumerFeePoolShare consumer_fee_pool_shares = 8
[ (gogoproto.nullable) = false ];
}

// The provider VAAS module's knowledge of consumer state.
Expand Down Expand Up @@ -84,3 +88,17 @@ message ValsetUpdateIdToHeight {
uint64 valset_update_id = 1;
uint64 height = 2;
}

// ConsumerFeePoolShare is a single depositor's share holding in a consumer
// fee pool, scoped to one denom. The triple (consumer_id, depositor, denom)
// is unique.
message ConsumerFeePoolShare {
uint64 consumer_id = 1;
string depositor = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
string denom = 3;
string shares = 4 [
(cosmos_proto.scalar) = "cosmos.Int",
(gogoproto.customtype) = "cosmossdk.io/math.Int",
(gogoproto.nullable) = false
];
}
41 changes: 41 additions & 0 deletions proto/vaas/provider/v1/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import "tendermint/crypto/keys.proto";
import "cosmos_proto/cosmos.proto";
import "cosmos/staking/v1beta1/staking.proto";
import "cosmos/base/query/v1beta1/pagination.proto";
import "cosmos/base/v1beta1/coin.proto";

service Query {
// ConsumerGenesis queries the genesis state needed to start a consumer chain
Expand Down Expand Up @@ -106,6 +107,13 @@ service Query {
option (google.api.http).get =
"/vaas/provider/consumer_genesis_time/{consumer_id}";
}

rpc ConsumerFeePoolClaim(QueryConsumerFeePoolClaimRequest) returns (QueryConsumerFeePoolClaimResponse) {
option (google.api.http).get = "/vaas/provider/v1/consumer_fee_pool_claim/{consumer_id}/{depositor}";
}
rpc ConsumerFeePoolClaims(QueryConsumerFeePoolClaimsRequest) returns (QueryConsumerFeePoolClaimsResponse) {
option (google.api.http).get = "/vaas/provider/v1/consumer_fee_pool_claims/{consumer_id}";
}
}

message QueryConsumerGenesisRequest {
Expand Down Expand Up @@ -277,3 +285,36 @@ message QueryConsumerGenesisTimeResponse {
google.protobuf.Timestamp genesis_time = 1
[ (gogoproto.stdtime) = true, (gogoproto.nullable) = false ];
}

message QueryConsumerFeePoolClaimRequest {
uint64 consumer_id = 1;
// bech32 address; if equal to the gov module authority, aliases to the
// distribution module account address
string depositor = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
}

message QueryConsumerFeePoolClaimResponse {
// claimable balance across all denoms; excludes zero-claim denoms
repeated cosmos.base.v1beta1.Coin claim = 1 [
(gogoproto.nullable) = false,
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"
];
}

message DepositorClaim {
string depositor = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
repeated cosmos.base.v1beta1.Coin claim = 2 [
(gogoproto.nullable) = false,
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"
];
}

message QueryConsumerFeePoolClaimsRequest {
uint64 consumer_id = 1;
cosmos.base.query.v1beta1.PageRequest pagination = 2;
}

message QueryConsumerFeePoolClaimsResponse {
repeated DepositorClaim claims = 1 [(gogoproto.nullable) = false];
cosmos.base.query.v1beta1.PageResponse pagination = 2;
}
73 changes: 73 additions & 0 deletions proto/vaas/provider/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ syntax = "proto3";
package vaas.provider.v1;

import "amino/amino.proto";
import "cosmos/base/v1beta1/coin.proto";
import "cosmos/msg/v1/msg.proto";
import "cosmos_proto/cosmos.proto";
import "gogoproto/gogo.proto";
Expand All @@ -27,6 +28,9 @@ service Msg {
rpc UpdateConsumer(MsgUpdateConsumer) returns (MsgUpdateConsumerResponse);
rpc RemoveConsumer(MsgRemoveConsumer) returns (MsgRemoveConsumerResponse);
rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse);
rpc FundConsumerFeePool(MsgFundConsumerFeePool) returns (MsgFundConsumerFeePoolResponse);
rpc WithdrawConsumerFeePool(MsgWithdrawConsumerFeePool) returns (MsgWithdrawConsumerFeePoolResponse);
rpc SweepConsumerFeePool(MsgSweepConsumerFeePool) returns (MsgSweepConsumerFeePoolResponse);
}

message MsgAssignConsumerKey {
Expand Down Expand Up @@ -165,3 +169,72 @@ message MsgUpdateConsumer {

// MsgUpdateConsumerResponse defines response type for MsgUpdateConsumer messages
message MsgUpdateConsumerResponse {}

// MsgFundConsumerFeePool deposits a single-denom amount into a consumer's
// fee pool and credits the signer with shares. If the signer is the gov
// module authority, funds are pulled from the community pool and the
// distribution module account is credited as the depositor.
message MsgFundConsumerFeePool {
option (cosmos.msg.v1.signer) = "signer";
option (gogoproto.equal) = false;
option (gogoproto.goproto_getters) = false;

string signer = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
uint64 consumer_id = 2;
cosmos.base.v1beta1.Coin amount = 3 [
(gogoproto.nullable) = false,
(amino.dont_omitempty) = true
];
}

message MsgFundConsumerFeePoolResponse {}

// MsgWithdrawConsumerFeePool withdraws tokens from the signer's share in a
// consumer fee pool across one or more denoms. Each amount is interpreted as
// "up to this much": if the signer's claim for a denom is less than the
// requested amount the handler delivers the full claim and burns all of the
// signer's shares for that denom. The transaction is atomic: if the signer
// has no shares at all for any denom in `amount` (or the pool has zero
// balance for that denom), the whole tx aborts.
// If the signer is the gov module authority, the withdrawal targets the
// distribution module account's shares and tokens are routed back to the
// community pool.
message MsgWithdrawConsumerFeePool {
option (cosmos.msg.v1.signer) = "signer";
option (gogoproto.equal) = false;
option (gogoproto.goproto_getters) = false;

string signer = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
uint64 consumer_id = 2;
repeated cosmos.base.v1beta1.Coin amount = 3 [
(gogoproto.nullable) = false,
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins",
(amino.dont_omitempty) = true
];
}

message MsgWithdrawConsumerFeePoolResponse {
// total tokens actually delivered (may be less than requested due to
// truncation in the partial-withdraw branch)
repeated cosmos.base.v1beta1.Coin amount = 1 [
(gogoproto.nullable) = false,
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins"
];
}

// MsgSweepConsumerFeePool distributes a consumer fee pool's balance pro-rata
// to all share-holders across the specified denoms (or all denoms if `denoms`
// is empty). Truncation residue per denom is forwarded to the community pool.
// Only the consumer owner may sign.
message MsgSweepConsumerFeePool {
option (cosmos.msg.v1.signer) = "signer";
option (gogoproto.equal) = false;
option (gogoproto.goproto_getters) = false;

string signer = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
uint64 consumer_id = 2;
// empty = all denoms with shares or balance for this consumer
repeated string denoms = 3;
}

message MsgSweepConsumerFeePoolResponse {}
35 changes: 32 additions & 3 deletions tests/e2e/e2e_debt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"time"

sdk "github.com/cosmos/cosmos-sdk/types"
)

// queryConsumerFeePoolAddress returns the provider-side fee pool account for a
Expand Down Expand Up @@ -55,9 +57,15 @@ func (s *IntegrationTestSuite) consumerBankSendDryRun() (string, error) {
return stderr.String(), err
}

// providerFundAddress sends tokens from val to addr on the provider chain.
// providerFundAddress sends `amount` from val to `addr` on the provider chain
// and blocks until the recipient's balance for the funded denom has grown,
// so callers can immediately issue txs from `addr`.
func (s *IntegrationTestSuite) providerFundAddress(addr, amount string) {
_, _, err := s.dockerExec(s.providerValRes[0].Container.ID, []string{
coin, err := sdk.ParseCoinNormalized(amount)
s.Require().NoError(err, "invalid amount %q", amount)
before := s.providerQueryBalance(addr, coin.Denom)

_, _, err = s.dockerExec(s.providerValRes[0].Container.ID, []string{
providerBinary, "tx", "bank", "send", "val", addr, amount,
"--from", "val",
"--home", providerHomePath,
Expand All @@ -67,6 +75,27 @@ func (s *IntegrationTestSuite) providerFundAddress(addr, amount string) {
"-y",
})
s.Require().NoError(err, "failed to fund provider address %s", addr)

s.Require().Eventuallyf(func() bool {
return s.providerQueryBalance(addr, coin.Denom) > before
}, 30*time.Second, 2*time.Second,
"balance of %s in %s did not grow after fund (before=%d)", addr, coin.Denom, before)
}

// providerFundConsumerFeePool deposits `amount` into the named consumer's
// fee pool via MsgFundConsumerFeePool, signed by val.
func (s *IntegrationTestSuite) providerFundConsumerFeePool(consumerID, amount string) {
_, _, err := s.dockerExec(s.providerValRes[0].Container.ID, []string{
providerBinary, "tx", "provider", "fund-consumer-fee-pool",
consumerID, amount,
"--from", "val",
"--home", providerHomePath,
"--keyring-backend", "test",
"--chain-id", providerChainID,
"--fees", "10000" + bondDenom,
"-y",
})
s.Require().NoError(err, "failed to fund consumer %s fee pool", consumerID)
time.Sleep(3 * time.Second)
}

Expand All @@ -93,7 +122,7 @@ func (s *IntegrationTestSuite) testConsumerDebtFlow() {
"consumer did not enter debt; last dry-run did not surface debt error")

s.T().Log("funding consumer fee pool on provider...")
s.providerFundAddress(feePoolAddr, "10000000"+bondDenom)
s.providerFundConsumerFeePool("0", "10000000"+bondDenom)

s.T().Log("waiting for consumer to exit debt (bank send should succeed)...")
s.Require().Eventuallyf(func() bool {
Expand Down
Loading
Loading