x/taxrail
Code: chain/x/taxrail (see DECISIONS.md
for why module code lives under chain/x/ rather than here).
Purpose
A split-payment primitive: a single transfer message that atomically routes a governance-registered percentage to a jurisdiction's collector treasury and the remainder to the recipient, in one transaction. All flows — collection and later spend — are on-chain and queryable.
Tax rules
MsgRegisterTaxRule(authority, jurisdiction_code, rate) is gated on the module authority
(defaults to x/gov). rate is a cosmossdk.io/math.LegacyDec in [0, 1]
(ErrInvalidRate otherwise) — one rule per jurisdiction code, upserted on each call, same
1:1 shape as x/registry's jurisdiction authority.
Taxed transfers
MsgTaxedTransfer(sender, recipient, jurisdiction_code, amount):
- Rejects any denom other than
ulcv(ErrUnsupportedDenom) — see Design decision below. - Looks up the jurisdiction's tax rule; no rule means the transfer is rejected outright
(
ErrNoTaxRule), not silently treated as 0% — an unconfigured jurisdiction should not let money move tax-free by omission. - Computes
tax_amount = floor(rate * amount)andremainder_amount = amount - tax_amount(sotax_amount + remainder_amount = amountalways, by construction and integer arithmetic — there's no floating point and no rounding remainder left unaccounted for). - Sends
tax_amountfrom the sender to the taxrail module account (acting as the jurisdiction's collector treasury) and credits the jurisdiction's ledger; sendsremainder_amountdirectly from sender to recipient.
The response returns both tax_amount and remainder_amount as Coins so callers can verify
the split without a follow-up query.
Collector treasury and spend
The taxrail module account holds all collected tax for every jurisdiction combined; a
per-jurisdiction JurisdictionLedger tracks each jurisdiction's share of that pooled balance.
MsgSpendCollectedTax(authority, jurisdiction_code, recipient, amount, reason) is
authority-gated — collector outflows require a governance proposal, per the top-level
spec, there is no path for a jurisdiction to spend its own collected tax unilaterally. It
debits the jurisdiction's ledger (failing with ErrInsufficientLedger if the jurisdiction
hasn't collected enough) and pays out from the module account to recipient, with a required
on-chain reason string.
State
tax_rule/<jurisdiction_code>→TaxRule(rate, set_at).jurisdiction_ledger/<jurisdiction_code>→JurisdictionLedger(denom, balance) — a jurisdiction with no ledger record yet reads back as a zero-value ledger rather than "not found," since every jurisdiction implicitly starts at a zero collected balance.
Queries
TaxRule(jurisdiction_code), JurisdictionBalance(jurisdiction_code). Both are hand-written
CLI commands (x/taxrail/client/cli/query.go) rather than AutoCLI-generated, because AutoCLI's
default aminojson response encoder doesn't respect gogoproto's customtype annotation and
would print TaxRule.rate as its raw internal scaled integer instead of a decimal — the exact
issue found and fixed for x/feemarket/x/emission in Phase A; see those modules' SPEC.md.
Events
EventTaxRuleRegistered, EventTaxedTransfer (sender, recipient, jurisdiction_code,
tax_amount, remainder_amount), EventCollectedTaxSpent.
Invariants
LedgerMatchesModuleBalanceInvariant — the sum of every jurisdiction's tracked
JurisdictionLedger.balance must equal the taxrail module account's actual ulcv bank
balance. TaxedTransfer and SpendCollectedTax are the only two code paths that touch either
side of this equation, and both update the ledger and move real coins in the same handler
call, so any divergence would mean a bug let one happen without the other.
Design decision: single denom (ulcv) only
JurisdictionLedger was initially designed with denom as part of its store key, but
QueryJurisdictionBalanceRequest only takes a jurisdiction_code — there was no way to
specify which denom's balance to fetch, making a multi-denom ledger unqueryable as
specified. Rather than redesign the query to take a denom parameter (adding complexity for a
use case not in the acceptance criteria), the module now hard-scopes itself to a single
DefaultDenom = "ulcv" constant and explicitly rejects any other denom in both
MsgTaxedTransfer and MsgSpendCollectedTax. See DECISIONS.md if this
needs revisiting for a future multi-denom requirement.
Limitations
- Single denom only (see above) — not a general-purpose multi-asset tax primitive.
- No per-transfer tax-rule versioning/history.
MsgRegisterTaxRuleupserts in place; there is no record of what rate applied to a transfer that already happened beyond what's in that transfer's own emitted event — reconstructing "what was the rate on date X" requires scanning event history, not a dedicated query. - Jurisdiction ledger balances are pooled in one module account, distinguished only by the
bookkeeping in
JurisdictionLedgerrecords, not by separate on-chain sub-accounts. This is fine given the invariant that keeps the ledger sum equal to the true pooled balance, but it means a jurisdiction's "treasury" is a purely logical, not physically segregated, balance.