-
Rate limiting is two halves. The global sliding window runs early, before any work is done, and needs no identity. The per-account caps run after signature verification, because
firstSignerreads the signer out of the message body: before verification that is a forgeable claim, and charging it there let a handful of malformed transactions lock any address out of any reachable node, for free and with no key. Neither half holds the limiter's mutex while the rest of the ante chain runs. -
Gasless ante: the custom rate-limit decorator relies on:
LUMEN_RL_PER_BLOCK(default 5)LUMEN_RL_PER_WINDOW(default 20)LUMEN_RL_WINDOW_SEC(default 10)
The binary refuses to start unless
--minimum-gas-pricesis unset or exactly0ulmn, guaranteeing that gasless transactions remain valid while ante decorators enforce quotas. Ante-time fee checks are selective: gasless native txs must keep fee0, IBC fee-bearing txs must carry a positiveulmnfee, andstaking.MsgEditValidatormust pay exactly1000000ulmn. -
Operator guard: Startup aborts when
--minimum-gas-pricesis non-zero; this prevents accidental deployment of fee-bearing configurations. -
DNS payload bounds:
MsgRegister/MsgUpdateenforce ≤64 records and ≤16 KiB combined payload. Oversized requests are rejected early. -
Where fees go: every module fee — DNS registration, renewal, transfer, update, bids, and the gateways action fee — is routed to the community pool through the distribution keeper. The fee collector is a fallback used only when that keeper is unavailable. This is deliberate: anti-spam revenue must not reach block producers, or they gain a stake in the spam. Monitor the community pool, not the fee collector.
-
Bids are escrowed:
MsgBidmoves the bid to the module account when it is placed and refunds the previous bidder in full when they are outbid. Before that, a bid was a promise — anyone could name an arbitrary figure from an empty wallet, leaving nobody able to outbid and settlement failing forever. -
Release spam: only addresses listed in
Params.allowed_publishersmay publish releases. Tunepublish_fee_ulmnandmax_pending_ttlto discourage abusive submissions. -
Proposal submission is a bond, not a cost.
min_initial_deposit_ratiois0, so the floor comes frommin_deposit_ratio(0.01) at 0.1 LMN, refunded when an under-funded proposal expires after 48 h. Neither the veto burn nor the full 10 LMN deposit applies on that path, because it never reaches voting. Raising the ratio to1.0removes the path — see "Votes to run after the upgrade" indocs/governance.md.min_deposit_ratioitself is not reachable by vote. -
Governance vote churn: revotes on the same proposal are throttled by a per-
(proposal_id, voter)cooldown of 10 blocks; the first vote needs no cooldown record. A vote from an account holding less thanmin_voting_stake_ulmn(1 LMN, governed byMsgUpdateMinVotingStake) is refused outright: it adds almost nothing to the tally, while the tally still iterates that voter's delegations and deletes the vote inside gov's EndBlocker — one block, every validator, and an error there is fatal. The guard used to ask only whether the voter held any delegation, which one ulmn satisfied, so it named a cost and set it to dust. It prices the flood rather than bounding it: an attacker with capitalCcan still castC / thresholdvotes, and the figure is nominal, so its protection erodes as bonded stake grows. Bounding the walk means replacing gov's tally throughCustomCalculateVoteResultsAndVotingPowerFn, which this release deliberately does not do. -
Distribution churn:
MsgSetWithdrawAddressrejects no-op updates and applies a 20-block cooldown per delegator.MsgWithdrawDelegatorRewardrejects zero-reward withdrawals. -
Disabled messages:
MsgFundCommunityPool,MsgDepositValidatorRewardsPoolandMsgCancelUnbondingDelegationare refused byBlockedMsgDecoratorinCheckTxandDeliverTxalike, so a block proposer cannot include one either. The last of these gave back themax_entriesslot an undelegation had just consumed, which made that cap a decoration. -
Multisig accounts cannot transact. This is a property of the chain, not a gap. PQC is mandatory and an account carries exactly one registered Dilithium key, so there is nothing to verify a T-of-K aggregate against: the ante refuses
MultiSignatureDatabefore anything else, whatever the account's link state. A multisig address can receive funds and never move them. Linking a PQC key to it changes nothing. Supporting multisig means a set of keys and a threshold in the registry, one Dilithium signature per sub-key — a 5-of-8 is ~26 KB of signatures alone — and is its own piece of work. Note also thatx/authz,x/groupandx/feegrantare not wired, so there is at present no shared-custody primitive on this chain: plan treasury custody accordingly. The client refuses to build one:keys add --multisigandtx sign --multisigare rejected with the reason, andtx multi-sign/tx multisign-batchare not registered at all. That guard lives in the CLI rather than on the chain because the chain cannot have it — an address issha256(pubkey)[:20]whatever the key behind it, the only on-chain signal is the public key stored on the account, and that is written bySetPubKeyDecoratorinside the ante. An ante that fails writes nothing and a multisig ante always fails, so the key is never stored: when funds arrive at a fresh multisig address the chain cannot tell it from any other new account. The intervention has to happen before the address exists. -
An unlinked account has no post-quantum protection, and linking is one-way. Under
policy=REQUIREDan account with no Dilithium key can send exactly one message — its ownMsgLinkAccountPQC— and everything else is refused. Until that lands, the account is protected by secp256k1 and nothing else, which is the assumption this whole chain exists to stop making. Whoever produces a valid classical signature first decides which Dilithium key the address carries, andSetAccountPQCrefuses every replacement afterwards, so there is no second attempt and no rotation.This is not a gap that can be closed. A first key has to arrive somehow, and the only authority that exists before it arrives is the classical one. What is bounded is the window: the exemption reads the account's link state, so it shuts the moment a key is stored, and no bundled link message re-opens it —
TestPQC_UnlinkedAccountIsProtectedByTheClassicalKeyAloneinapp/measures both halves end to end on a real node.So the rule is operational, and it has two directions:
- Link before funding. A cold address, a validator's withdraw address, an exchange deposit address — anything that only ever receives and never signs — is not covered by this chain's post-quantum guarantee until it has linked, however much it holds. Treasury procedure should link an address as its first act, not its first spend.
- Back the Dilithium key up like the account depends on it, because it does. There is no rotate verb, by design. A lost key leaves the funds visible, attributable and permanently unspendable — the account can neither transact nor re-link. Store it as described under "PQC key material" below, and test the restore.
Note the shape of the risk, since it is the opposite of the usual one: what is dangerous is not linking early, and what is unrecoverable is losing the key after.
-
DNS update PoW: the digest is
sha256(fqdn|creator|updated_at|nonce). It commits toupdated_atso the work is a cost per update rather than a one-time cost per(name, owner)pair — a nonce stops being valid the moment the update it authorised lands. Two updates inside the same second read the sameupdated_atand so share a nonce;update_rate_limit_seconds(60 on mainnet) is what closes that, and at0the proof is not the bound anyway. -
Send tax scope. The tax applies to the base denomination only, matching
min_send_ulmn's scope. It used to apply to every denomination, and the ante refuses a coin whose tax truncates to zero — so at a 1% rate no amount under 100 base units of anything could move, which on a bridged asset was an undocumented floor with nothing behind it. -
Signature count:
tx_sig_limit(7) is enforced bySigCountDecorator, ahead of the rate limiter. The SDK's ownValidateSigCountDecoratoris deliberately not installed: it countsCountSubKeysoverGetPubKeys(), which is nil for any signer that omits its public key, so a transaction whose signers all leave that field empty would count as zero. Keys are resolved from the account before being counted, and multisig sub-keys count individually.The decorator also decodes
TxRawdirectly and requires equal lengths forTxRaw.Signatures,SignerInfosand derived signers. Both a missing signature entry and a surplus raw signature are rejected before signature verification, preventing ignored bytes and the SDK wrapper's out-of-range access. -
Message count: every transaction is limited to 64 messages by
MaxMessagesDecorator, before the message guards, signature verification and state reads. This applies in bothCheckTxandDeliverTx; the node-local rate limiter remains an additional admission control, not the consensus message bound. -
The IBC exemption follows the relayer, not the message type — and it buys fees, not admission. Core IBC messages are exempt from the per-message fee when every signer is on
ibc_relayer_allowlist, because relayers submit roughly a hundred client updates for every user transfer and pricing them would end the relaying IBC depends on. That exemption used to be granted on the message type, so anyone could claim it — and the most expensive computation this application performs, light client verification, sat behind it at one ulmn. It now follows the same list that governs the PQC bypass. An allowlisted relayer keeps today's fee behaviour exactly; everyone else pays the ordinary price. Nobody is refused. Core messages are counted and charged individually, so a transaction mixing aMsgTransferwith two client updates pays for the transfer and both updates: only an all-core transaction from allowlisted signers is subsidised, and a mixed one never inherits it.The per-account rate limit exempts nobody, on the allowlist or off it. Admission bounds the work this node performs before anything has been paid for, which is not what an allowlist entry buys, so a relayer takes its turn like every other account. Those limits are node-local
CheckTxpolicy —LUMEN_RL_PER_BLOCK,LUMEN_RL_PER_WINDOW,LUMEN_RL_WINDOW_SEC— so a relayer needing a wider window runs its own node.The global rate-limit window is the one place IBC traffic is still skipped, and the asymmetry is intentional: it runs before signature verification, where the signer is an unauthenticated claim, so gating it there would look like a control and be none.
-
Client updates are bounded independently of who sends them.
IBCClientGuardDecoratorrefuses atrust_levelof 1/1 — the commit check is strictly greater-than, so such a client can accept no header at all and exists only to make every update verify the whole set — anytrust_levelabove 2/3, and any header carrying more than 512 validators or commit signatures, against CometBFT'sMaxVotesCountof 10 000. A price bounds how often; this bounds how much. It is not conditional on the allowlist, so a stolen relayer key does not unlock it.The 2/3 ceiling is the second half of the same rule, and it was missing. Refusing only 1/1 removed the value where the early exit is unreachable and left every value where it is merely expensive: a commit check stops once the tally passes the trust level, so at 999/1000 it stops after 99.9 % of the set — one thousandth cheaper than the value that was refused. Two thirds is the highest value relayer tooling exposes, so nothing anyone runs is affected: 1/3, what every real chain uses, and 2/3 both pass. It applies to
MsgCreateClientonly, so no client that already exists is touched. -
Message prices:
delegate_fee_ulmn,redelegate_fee_ulmn,transfer_fee_ulmnandset_withdraw_addr_fee_ulmnare charged in the ante, before the message runs, so a transaction that fails still pays for the block space it took. All four ship in force at 1000ulmn, written by the v2.0.0 upgrade handler rather than left to a vote: the gap between the upgrade height and the first successful proposal is four days at minimum, and it would otherwise be the window in which every one of them is free. Governance changes them afterwards, to zero included. Seedocs/messages.mdfor the full cost surface. -
Identity has a price: the link proof-of-work commits to the account address —
sha256(creator || "|" || pub_key || nonce). It did not, once, which meant one solved nonce could link unlimited accounts. Several bounds assume an account costs something: validator creation,max_entriesper account, and the per-signer rate limit. Keeplink_fee_ulmnabove zero, or raisepow_difficulty_bits. v2.0.0 satisfies this by writinglink_fee_ulmn = 1000in its upgrade handler, which matters because the same handler setspow_difficulty_bitsto0— the new digest would otherwise reject every nonce mined by a client still on the old formula. Until the difficulty is voted back up, the fee is the only one of the two costs in force, so a proposal taking it to zero removes the last one. -
No gas means bytes are the only ceiling: there is no cap on a transaction's declared
gas_limitandmax_gasis-1, so per-transaction computation is bounded only bymax_bytes. Any handler whose cost grows faster than its input size is a vector; review new handlers against that, not against gas. -
REST exposure: place a TLS-enabled reverse proxy with per-IP rate limiting in front of
:2327if the API is exposed to the public internet. -
Keys: avoid the test keyring backend on production nodes. Prefer OS keyrings, KMS, or HSM integration for validator and publisher keys.
-
Governance: parameter changes go through
MsgUpdateParams(per-module authority). Document decisions and keep defaults version-controlled to aid audits.
- PQC is mandatory for EOA-signed transactions: the keeper forces
policy=REQUIREDand a missing or invalid signature is rejected. There is no exemption by message type. - There are exactly three exemptions, all by signer rather than by message:
- An account that has not linked yet. Nobody can PQC-sign the transaction that registers their first PQC key
with the key it registers, so
pendingLinkForSignerlets a signer through when the transaction carries their ownMsgLinkAccountPQCand the account holds no key. It is keyed to the account being unlinked, not to the link message, so it covers every other message in the same transaction — a classical signature alone moves money. See "An unlinked account has no post-quantum protection" above for the operational consequence. - IBC relayers on the allowlist.
shouldBypassPQCSigForIBCRelayerskips the check entirely when every message in the transaction is an IBC core message and every signer appears inibc_relayer_allowlist(anx/pqcparameter, set by governance). It exists so relayers, which are third-party infrastructure, are not required to carry a Dilithium key. From any account not on that list the same messages are refused. - Genesis height. The decorator returns early at
BlockHeight() <= 1, which is what lets agentxbe collected into a genesis file before any account can have a linked key.
- An account that has not linked yet. Nobody can PQC-sign the transaction that registers their first PQC key
with the key it registers, so
- Approved backend: CIRCL Dilithium3. The
pqc_oqstag is a compatibility build selecting the same implementation, not a separate PQClean backend; release builds must remain byte-for-byte compatible. - Account rotate is disabled by design (no public CLI verb). Use
link-accountto attach initial PQC material. - See
docs/modules/pqc.mdfor signing, parameters, and CLI usage.
- Import keys with
--pubkey-file/--privkey-file. The inline--privkeystill works and should not be used: a command-line argument is readable in/proc/<pid>/cmdlineby any process of the same user, captured byps, by audit logs and by whatever collects container output, and kept in shell history. pqc-showprints name, scheme, fingerprint, date and public key. The private key requires--unsafe-show-private.pqc-generateechoes the private key only when neither--namenor--priv-outkeeps it.- Encrypt the keystore:
--pqc-passphrase-file, orLUMEN_PQC_PASSPHRASE_FILE. Without one the keys are written in plaintext and every command that writes says so.
- Two halves: a global sliding window early in the ante chain, and per-account per-block and per-window caps after signature verification. See "Rate limiting is two halves" above for why the second waits.
- Environment knobs:
LUMEN_RL_PER_BLOCK,LUMEN_RL_PER_WINDOW,LUMEN_RL_WINDOW_SEC,LUMEN_RL_GLOBAL_MAX. - Values are clamped to a range, not to a direction. The clamps are
[1, 1000]per block (default 5),[1, 1000]per window (default 20),[1, 600]seconds of window (default 10) and[10, 100000]globally (default 300) — so an operator can loosen every one of them by two to three orders of magnitude, which is effectively "off". This page used to claim you could only tighten them; that was never true, and it could not be: these are node-local and non-consensus, and an operator who wants them gone can rebuild the binary. Treat them as a default worth keeping rather than a guarantee the chain enforces.
- Transactions: ≤64 messages. Proposal handlers also cap each transaction at 1 MiB and each signer's cumulative transaction bytes at 1 MiB per proposal.
- DNS: labels ≤63 chars, FQDN ≤255, ≤64 records per tx, max 16 KiB combined record payload, registration ≤365 days, and a bid amount ≤80 characters — two above the 78 decimal digits a 256-bit
Intcan hold. That last one is a liveness bound rather than an input cap:big.Intdecimal parsing is superlinear, so an unbounded amount let one free transaction stall a block. - Gateways: operator metadata ≤1 KiB, contract metadata ≤1 KiB, endpoint strings ≤64 chars, and
months_total≤MaxFinalizeDelayMonths(120). That last one is a fund-safety bound, not a size bound: the escrow pays out(total - tax) / months_totalper month in integer arithmetic, and a large enough term truncates that to zero — at which pointClaimPaymentanswers "no payout due" forever,FinalizeContractcan never satisfyclaimed_months >= months_total, and the only remaining exit isCancelContract, which refundsprice_ulmn * refund_months(also zero) and hands the whole escrow to the gateway as the penalty. Coins are conserved at every step, which is why the escrow-conservation tests were satisfied. The handler also refuses a per-month payout of zero outright, because the cap alone stops closing the hole if governance votesmin_price_ulmn_per_monthdown near 1 against a 20 %service_tax_bps. - Release: version ≤64 chars, channel ≤32 chars, notes ≤8 KiB (UTF‑8, only
\n/\tcontrol chars). Artifact URLs ≤1024 chars (http/https, ipfs/lumen schemes), signature blobs ≤2048 bytes. - PQC: scheme identifiers ≤32 chars, Dilithium public keys ≤4 KiB.
- CI workflow “Release Safety” and
make pre-releaseverify:- No test-only/noop PQC symbols in the binary.
./devtools/scripts/go_with_pkgs.sh vet, unit tests, static analysis, vuln scan, and preflight tests.
- Proposer validation:
PrepareProposalandProcessProposalshare the 1 MiB transaction and per-signer limits; proposals above the 5 MiB application budget or containing malformed/over-budget transactions are rejected. Optimistic execution is disabled by default until these budgets have production load measurements. mempool.max_txs_bytes. v2.0.0 sets the default to 128 MiB; CometBFT ships 1 GiB, which is self-limiting on a chain with fees and is not one here — at the 21 MiB a transaction may reach, about fifty of them fill a gigabyte of a node's memory for nothing. The default is written intoconfig.tomlatinit, so it reaches new nodes only: operators of existing ones have to set it in their ownconfig.toml.query_gas_limit. v2.0.0 sets the default to3000000; the SDK ships0, which means unlimited. The custom modules clamp their own page limits, but the SDK's query servers do not:/cosmos/auth/v1beta1/accountswithpagination.limitat the top of the uint64 range, or anycount_total=trueover a large store, walks and serialises it. This meter is separate from transaction gas, which this chain does not charge, and it turns an unbounded query into a failed one. Like the mempool setting above it is written intoapp.tomlatinit, so it reaches new nodes only — operators of existing ones have to set it themselves. Raise it on a node that serves an indexer.- Do not expose
:2327directly; put TLS reverse proxy + IP rate-limits in front. Good advice, but after v2.0.0 it is no longer the only bound on a query. - Avoid the test keyring in production; prefer OS keyrings/KMS/HSM for validators & publishers.
- Keep defaults and param changes versioned; review governance updates before rollout.
- Please open a private security advisory on the repository (GitHub Security → “Report a vulnerability”), or contact maintainers via the email listed in the repository profile if available.