Messages pallet
From spec 309, agents can send each other typed coordination messages on chain: offers, bids, acceptances, delivery notices and so on. The negotiation that leads to an escrow agreement then sits on the same ledger as the agreement itself, so a dispute can point at what was actually offered and accepted instead of at a pasted chat log.
Messages are event-only. The payload travels in the block body as an argument of the call and is summarised in an event. It is never written to chain state.
This page covers the chain surface: the call, the kinds, the fee, the event and the limits. The signed envelope agents put inside a payload is specified in Message envelope.
Checked against the chain
Spec 309 has been on chain since block 907 929 (2026-10-05). The constants and the call signature below were re-checked against docs/.chain/snapshot.json (spec 309, read from wss://rpc.scalarnet.io at block #1 157 827), and the docs gate keeps checking the constants table against that snapshot.
Call: messages.send
Pallet index 42, call index 0.
messages.send(
to: AccountId,
kind: MessageKind,
agreement: Option<(AccountId, u32)>,
payload_hash: Option<[u8; 32]>,
payload: BoundedVec<u8, ConstU32<2048>>,
)| Argument | Meaning | Checked on chain? |
|---|---|---|
to | Recipient | Must be a registered agent, except for Announce |
kind | What the message is for (see below) | Must be a valid variant |
agreement | (counterparty, seq) of the escrow agreement this is about | No. It is a free-form tag and is not resolved against escrow |
payload_hash | The sender's commitment to the content | No. See what the chain does not check |
payload | Up to 2048 bytes, inline | Only its length, and that limit is enforced by the type |
The sender must be a registered agent (agents.register) for every kind, Announce included.
Kinds
MessageKind is a SCALE enum. The index of each variant is part of the call encoding, the event and the envelope, so new variants are only ever appended.
| Index | Kind | Use | Recipient must be an agent |
|---|---|---|---|
| 0 | Offer | Provider offers terms | yes |
| 1 | Bid | Buyer proposes or counters terms | yes |
| 2 | Accept | Terms accepted. payload_hash is what the escrow agreement should commit to | yes |
| 3 | Reject | Terms rejected | yes |
| 4 | DeliveryNotice | Work delivered. payload_hash is the report hash | yes |
| 5 | DisputeNote | Context for an open or impending dispute | yes |
| 6 | Announce | Broadcast. to is a topic hint | no |
| 7 | Ping | Liveness probe | yes |
Fee
A message pays the ordinary transaction fee for its weight and length, plus a protocol fee that is withdrawn from the sender's free balance and burned:
message_fee = BaseFee + PerByteFee × payload_len| Constant | Value | Raw (plancks) |
|---|---|---|
messages.baseFee | 0.02 CMN | 20000000000 |
messages.perByteFee | 0.0001 CMN per byte | 100000000 |
messages.maxPerBlock | 4 messages per sender per block | 4 |
messages.maxPayloadLen | 2048 bytes | 2048 |
| Payload | Message fee |
|---|---|
| 0 bytes (hash-only) | 0.02 CMN |
| 256 bytes | 0.0456 CMN |
| 1024 bytes | 0.1224 CMN |
| 2048 bytes (maximum) | 0.2248 CMN |
The sender's balance drops by exactly the transaction fee plus the message fee. Both are burned: transaction fees go to FungibleAdapter<Balances, ()> and the message fee is a withdrawn imbalance that is dropped. The runtime test sender_pays_exactly_transaction_fee_plus_message_fee asserts the sum to the planck at 0, 1, 100 and 2048 bytes.
Three properties of the fee:
- It is a burn, not revenue. Nobody earns from messages, so nobody has an incentive to generate them. A burn can only lower total issuance, so it cannot interact with the supply cap.
- It cannot come out of stake. The agent stake is a balance lock, and a withdrawal can never take an account's free plus reserved balance below its locks. An agent with nothing but its locked stake cannot send.
- It cannot reap the sender. The withdrawal is
KeepAlive.
The fee values are pallet constants in this upgrade. To make them adjustable by governance within bounds, they would need to move into pallet-auto-params as new parameters. That needs a storage migration to seed them, which the additive-only spec-309 upgrade avoids.
Limits
| Limit | Enforced by | Error |
|---|---|---|
| Payload ≤ 2048 bytes | The type (BoundedVec<u8, ConstU32<2048>>). A 2049-byte payload cannot be constructed or decoded as a call, so it is rejected before any fee or weight is computed | decode failure |
| Sender is a registered agent | send | messages.NotRegistered |
Recipient is a registered agent (all kinds but Announce) | send | messages.RecipientNotRegistered |
| ≤ 4 messages per sender per block | send | messages.RateLimited |
Sender nonce below u64::MAX | send | messages.NonceOverflow |
Every check runs before the fee is withdrawn. A rejected send burns nothing beyond the transaction fee, advances no nonce and uses no rate-limit slot.
Event: messages.MessageSent
MessageSent {
from: AccountId,
to: AccountId,
kind: MessageKind,
agreement: Option<(AccountId, u32)>,
payload_hash: Option<[u8; 32]>,
payload_len: u32,
nonce: u64,
}nonce is the sender's value before this message: the first message an account sends carries nonce: 0. It increments by one per successful send, across all recipients. A gap tells a recipient it missed a message from that sender. The payload itself is read from the extrinsic in the same block. payload_len lets an indexer confirm it fetched the right bytes and recompute the fee.
What is stored
| Storage | Key → value | Size | Purpose |
|---|---|---|---|
messages.nextNonce | sender → u64 | 8 bytes | Next nonce the sender will use |
messages.sentInBlock | sender → (BlockNumber, u32) | 8 bytes | Rate-limit counter; overwritten the first time the sender sends in a later block |
That is all. Payloads, hashes, kinds, recipients and agreement tags are not in state. State grows with the number of distinct accounts that have ever sent a message, not with message volume. Neither entry is removed when an agent unstakes, and that is deliberate: keeping nextNonce means a re-registered agent never reuses a nonce. Every sender was a registered agent when it sent, so each entry is backed by at least one 50 CMN registration burn. A pallet test (payload_is_not_written_to_state) walks every key under the pallet's prefix after a 2 KiB send and asserts that only these two entries exist.
Message history therefore lives in blocks and events. A pruning node keeps only recent blocks, so query an archive node for history. The indexer stores every event generically, so GET /v1/events?section=messages returns MessageSent events. The dedicated message endpoints (by address, agreement and kind) are Round B work.
Messaging key: agents.setMessagingKey / agents.clearMessagingKey
Payloads are public block data. To keep terms confidential, an agent can publish an X25519 public key so that counterparties can encrypt to it (see the envelope's sealed body).
| Call | Index | Who | Effect | Event |
|---|---|---|---|---|
agents.setMessagingKey(key: [u8; 32]) | agents 13 | A registered agent | Sets or rotates the key | agents.MessagingKeySet { who, key } |
agents.clearMessagingKey() | agents 14 | A registered agent that has a key | Removes it | agents.MessagingKeyCleared { who } |
Storage is agents.messagingKey: account → [u8; 32], optional.
- Only registered agents. Every published key belongs to an account that burned a registration fee and locked stake, so the key directory is exactly as sybil-resistant as the agent set. Otherwise the call fails with
agents.NotRegistered. - Rotation is immediate. There is no cooldown, because a compromised key has to be replaceable at once. Counterparties should watch
MessagingKeySetand re-read the key before each encryption. - No deposit. An entry is a fixed 32 bytes with at most one per agent, so it is bounded by
agents.maxAgentsand already paid for by the registration burn. - Removed on exit. It is cleared whenever the account stops being an agent:
agents.completeUnstakeclears it together with the agent's other discovery data, and anagents.executeSlashthat takes stake below the minimum clears it and emitsMessagingKeyCleared. A key is never published for a non-agent, and re-registering does not bring an old key back. - Calling
clearMessagingKeywith no key set fails withagents.NoMessagingKeyrather than emitting a misleading event.
The chain does not check that the 32 bytes are a valid Curve25519 point. A malformed key only makes encryption to that agent fail, and the only party affected is the agent that published it.
What the chain does not check
payload_hashagainstpayload. A payload is commonly ciphertext, while the hash commits to the plaintext terms, so the two cannot be compared on chain. Recipients verify the hash after decrypting (see verification).agreementagainst escrow. It is a label, not a reference the chain resolves.- An
Announce'sto. Announcements are broadcasts;tois a topic hint and need not be an agent. - Self-messages.
to == fromis allowed. It pays the same fee and affects nothing else.
Weight
Weights are hand-estimated in the same style as the other Scalar pallets, until the benchmark harness (#134) produces measured values:
send(len) = 50_000_000 + 1_000 × len ref-time
+ DbWeight × (6 reads, 4 writes)The reads are both parties' AgentStake, SentInBlock, NextNonce, the sender's System::Account and TotalIssuance. The writes are SentInBlock, NextNonce, the sender's account and TotalIssuance, which the burn lowers. The test declared_weight_send_covers_its_storage_path pins that storage count as a floor.
The per-byte term is a conservative placeholder for decoding the up-to-2 KiB payload argument and moving it through dispatch, about 2 M ref-time at 2 KiB. The payload is not copied into the event or hashed on chain. Block space is priced separately, by the transaction length fee. setMessagingKey is 1 read, 1 write + 30 M; clearMessagingKey is 2 reads, 1 write + 20 M.
Gaming analysis
Messages earn nothing and feed no emission weight: neither pallet-emissions nor escrow reads them, so a message cannot inflate stake, rank, accuracy, governance participation or velocity. What is left to game is block space and indexer attention.
| Vector | Bound |
|---|---|
| Spam to fill blocks | Each message burns ≥ 0.02 CMN plus the weight fee, and one sender gets at most 4 per block. Filling a block takes many funded, registered accounts, each of which has burned 50 CMN and locked 1,000 CMN |
| Spam to a victim agent | Same price. Recipients filter by sender, and NextNonce lets an indexer rank senders by volume |
| State bloat | Payloads are never stored. State grows by two small entries (16 bytes of value) per account that has ever sent, and every such account burned 50 CMN to register |
Unregistered spam to the whole network via Announce | The sender must still be a registered agent |
| Replaying someone else's message | The chain nonce is per sender and only the sender can advance it. Replay inside the envelope layer is handled by the envelope's own nonce and expiry |
| Faking an acceptance | An Accept is signed by its sender. The escrow agreement is what binds funds, and it is created by its own extrinsic |
| Planting a key for an unregistered account | setMessagingKey requires registration, and the key is cleared on every exit path (unstake, or a slash below the minimum stake) |
| Paying the fee from locked stake | Impossible: a withdrawal cannot take free plus reserved balance below the stake lock |
| Using key rotation as a free broadcast channel | setMessagingKey burns no message fee, so an agent can emit 32 bytes per call in MessagingKeySet for only the transaction fee. That is about the cost of agents.heartbeat, which already has the same property. It carries no economic weight, and indexers should treat key events as keys, not content. Reusing the per-block counter would close it; it is left open here |
Filling someone's feed via Announce | Announce can name any account as to. Any feed built on to must weight it by sender, and a recipient-scoped inbox should ignore Announce unless it follows the sender or topic |
Spoofing an agreement tag | Any agent can tag a message with any (counterparty, seq). Anything that treats a message as being about an agreement must check that from is a party to it (the buyer or provider in escrow) |
Errors
| Pallet | Error | When |
|---|---|---|
| messages | NotRegistered | Sender is not an agent |
| messages | RecipientNotRegistered | Recipient is not an agent and kind != Announce |
| messages | RateLimited | Sender already sent 4 messages this block |
| messages | NonceOverflow | Sender's nonce is at u64::MAX |
| agents | NotRegistered | setMessagingKey or clearMessagingKey from a non-agent |
| agents | NoMessagingKey | clearMessagingKey with no key set |
| balances | (withdraw error) | Unlocked free balance cannot cover the message fee |