Tokenisation and crowdfunding
An issuer opens an offering, subscribers commit funds, and if the round succeeds the chain mints a token representing the thing that was funded. The chain's job is narrow: hold the money honestly until the outcome is known, then either mint or refund. Everything about what was funded — the business, the building, the harvest, the bond — lives off-chain in an agreement the chain never sees.
What the chain enforces
Three things, and nothing else:
- Subscribed funds are held by the module, not the issuer. They move to the
issuer only at settlement, and only if settlement succeeded. This is the same
invariant
x/treasurydepends on: locked-versus-available is only real because the funds genuinely live in the module account. - The outcome is deterministic. Given the offering record and the block time, any node computes the same settlement. Nobody decides it.
- Approval gates issuance. Anyone may propose an offering; only governance may let one take money.
Valuation, due diligence, whether the warehouse exists, whether the business is solvent — none of that is consensus. It is what the approval step is for, and the approval is a human judgement recorded on-chain, not a computed one.
The offering lifecycle
CreateOffering -> PENDING
| ApproveOffering (gov only)
v
OPEN ---- Subscribe / WithdrawSubscription
|
| close_time reached
v
CLOSED ---- Settle (permissionless crank)
|
+-----------+-----------+
v v
SETTLED REFUNDED
CreateOffering is permissionless and writes a PENDING record that can hold
no funds. ApproveOffering accepts only the gov module account as signer.
Keeping those in separate messages is deliberate: an approval path reachable by
the applicant is the critical bug in this module, and one message with an
internal branch is exactly how that bug gets written.
Settlement is a permissionless crank rather than an EndBlocker sweep. An
EndBlocker that iterates every closed offering is a denial-of-service surface
that grows with usage; a crank puts the cost on whoever wants the outcome, and
anyone can pay it. The offering is fully determined at close_time, so nothing
depends on when the crank runs.
Two raise modes
Declared per offering, scrutinised at approval:
RAISE_MODE_ALL_OR_NOTHING — if raised < target at close_time, every
subscriber is refunded in full and nothing is minted. This is the model retail
participants can be defended with, and the one a regulator recognises.
RAISE_MODE_KEEP_WHAT_YOU_RAISE — the issuer takes whatever was raised and
tokens mint pro-rata. Appropriate for an issuer funding a divisible thing (ten
hectares planted instead of thirty), dangerous for an indivisible one. Half a
bridge is not half as useful as a bridge, and approval is where that gets
caught.
The mode is on the record because both are legitimate and the difference is about the asset, not the platform. Every client must state which mode applies in plain words before a user commits funds — "you get your money back if this does not reach its goal" or "you do not". That sentence is most of the protection.
The token
Settlement mints a plain bank denom, tok/{offering_id}/{symbol}. It transfers
freely and can be pooled on x/amm like any other coin. The offering id is in
the denom because symbols are not unique and never will be — two issuers will
both want SOLAR.
Free transfer is a deliberate choice with a real cost, recorded here so nobody
has to re-derive it. A freely tradable token representing SME equity or a
municipal bond is a bearer instrument that can reach anyone with a wallet,
including people who never signed the agreement that gives it value and
jurisdictions where offering it is an offence. The chain cannot fix this; the
approval step and the issuer's own terms have to. If that proves untenable for
a given asset class, the retrofit is a send restriction on the tok/ prefix —
which is consensus-breaking and therefore a decision to make before mainnet,
not after.
NFTs are minted by a declared authority, never by anyone
Fungible offering tokens are minted by settlement, so their supply is bounded by what was actually subscribed — the maths is the permission. Non-fungible assets have no such bound. A title deed, a warehouse receipt, a vehicle registration: each is a claim that someone with standing has to make, and if anyone can mint one then the token means nothing. A registry that will attribute a deed to whoever asks is not a registry.
This is not a user-facing feature. There is no MsgCreateCollection that a
subscriber, an issuer, or an application can send — collections are chain-level
constructs and they come into existence only by governance. That is the
difference from the fungible path above, where anyone may apply to run an
offering and governance merely approves it. Here there is no application step to
approve, because a registry of deeds is not something a chain grants on request.
So minting is two-tier, the same shape as x/custody's attestors and
x/oracle's appointed valuers:
Governance appoints the authority. MsgSetCollectionAuthority accepts only
the gov module account as signer, and binds an address to a collection: the
lands ministry to deeds/ci, the licensed warehouse operator to their own
receipts, the vehicle registry to theirs. Appointment is a public, revocable,
on-chain act.
The authority mints, and only into its own collection. MsgMintAsset
checks the signer against the collection's authority and rejects everything
else. A mint names its recipient, so the asset is attributed to a wallet at
creation and never exists unattributed. There is no self-mint-then-transfer
path, because that path is where an authority laundering assets to itself
becomes indistinguishable from an authority doing its job.
Three rules that follow, and are easier to hold than to retrofit:
- An empty authority is not a collection. A collection whose authority is
unset or revoked accepts no mints. Not "falls back to governance" — refuses.
The failure mode of a permissive default here is unlimited issuance, which is
precisely what
x/custodyrefuses when it rejects an attestation threshold below two. - Revocation does not burn. Removing an authority stops future mints and
touches nothing already issued. Deciding an existing asset was wrongly issued
is a seizure, and seizures go through
x/enforcement, where two thirds decide — not through a registry keeper. - The authority cannot move what it minted. Once attributed, the asset is the holder's. An authority that can move assets after issuance is a custodian with unlimited power over everything it has ever touched, which is the thing the user explicitly ruled out for funds and applies with equal force here.
Whether these NFTs transfer freely is a separate question from the fungible tokens above and should not inherit that answer by default. A deed that trades without the registry knowing is a deed the registry cannot honour.
The instrument: a closed-end vehicle with a defined life
An RWA is tokenised to raise money against it, not to trade its title forever. The shape is:
raise -> ACTIVE ---- income distributed to holders ----+
| |
| the asset is sold in the real world |
v |
REALISED ---- proceeds distributed the same way
|
v
CLOSED (supply zero, NFT burned)
The NFT is the asset's identity and its encumbrance record. It is not a thing that trades. It exists so the chain has one object to attach the shareholding and the obligation to, and it is burned when the vehicle closes.
The tokens are the shareholding, and they represent a fixed percentage of
the asset, not all of it. A raise that funds 40% of a warehouse mints tokens
carrying 40% of its economics; the sponsor keeps the rest. That share,
holder_share_bps, is fixed at settlement and can never be edited — an issuer
who could revise it after the raise could dilute every shareholder without
minting a single token.
Income during ACTIVE and proceeds at REALISED are the same mechanism: an
amount arrives, holder_share_bps of it enters the vault, and
cumulative_per_token rises. The sale is not a special case in the accounting.
It is the last distribution, and it is larger.
Burning is how you claim
The tokens are not destroyed by the chain at the end. Burning a token is the
act of claiming its share — MsgRedeem burns and pays out in one step.
Supply falls to zero as holders claim, and the NFT burns when it reaches zero.
This ordering matters. A design that burns tokens and then expects holders to claim strands the money of everyone who was slow, asleep, or dead, and leaves the chain holding funds it can no longer attribute. Making the burn the claim means an unclaimed share is still a live token with a live entitlement, visible to its owner in any wallet, indefinitely.
Trading must stop at realisation
The moment the asset is sold, each token becomes a claim on a known, fixed pot. An AMM pool still holding tokens will keep quoting a price from its reserves, which is now simply wrong — and a wrong price next to a known redemption value is a free lunch that gets taken within a block.
So REALISED makes the token non-transferable except along the redemption path.
Pools stop; liquidity providers withdraw and redeem like everyone else. This is
the one place the free-transfer rule is deliberately suspended, and it is
suspended to protect the people it would otherwise rob.
The sale price is the attack
Everything above assumes the chain learns what the asset sold for. It cannot know. Somebody reports it, and an issuer who under-reports the sale price steals the difference from every shareholder — in one transaction, at the moment of maximum value, with no further obligations to anyone afterwards.
This is the largest number in the vehicle's life and the last one anybody can contest, so a self-reported figure is not acceptable. It needs the shape already used elsewhere on this chain:
- an attestation threshold, as
x/custodyrequires for crediting a deposit, where one attestor is not a threshold but a single point of unlimited theft; or - the appointed independent valuer from
x/oracle, which exists for exactly this class of judgement; or - governance, where the figure is voted with the evidence attached.
Decided: the issuer alone can never set it. The verification mode is a property of the collection, chosen by governance when the collection is created, because a deeds registry and a farm co-operative do not warrant the same ceremony:
VERIFY_VALUER the appointed independent valuer signs the figure
VERIFY_ATTESTORS m-of-n attestors agree, m >= 2
VERIFY_GOVERNANCE voted, with the evidence attached to the proposal
VERIFY_ATTESTORS with m = 1 is refused at validation, for the same reason
x/custody refuses an attestation threshold below two: one attestor is not a
threshold, it is a single point of unlimited theft.
And the attestors are appointed by governance, not by the seller. A
collection carries a register, replaced by MsgSetCollectionAttestors, which is
authority-gated alongside CreateCollection and SetCollectionAuthority. If the
seller could appoint the accounts that check the seller, the register would
restate the problem rather than fix it.
Two decisions inside that are worth recording. The register replaces rather than appends, because one that could only grow would let a single careless proposal dilute a careful appointment for good. And attestations already recorded against a reported sale are not revisited when the register changes: they were made by an appointed attestor at the moment they were made, and rewriting history to match a later appointment would let an authority manufacture or destroy a quorum after the fact — a worse power than the one the message exists to constrain.
This paragraph is newer than the rest of this section, and
the defect it closes is the
reason: for the module's whole life until 2026-08-27, AttestSale never checked
who was attesting, so everything argued above was defended by nothing.
A reported price needs a challenge window
Verification alone is not enough, because redemption is irreversible. Once holders start burning tokens against a figure, the money is gone; discovering afterwards that the warehouse sold for twice the reported price leaves nothing to correct with.
So a verified sale price does not become claimable immediately. It is recorded,
it is public, and it sits for a challenge window before REALISED opens the
redemption path. During that window any token holder may raise a dispute, which
suspends redemption and refers the figure to governance.
The window is the cheapest protection in the module. It costs a few days at the end of a vehicle that ran for years, and it converts the one irreversible mistake into a recoverable one. Without it, verification only means the theft needed two signatures instead of one.
The window and the mode follow the asset, not a constant
How hard a sale price is to check, and how much is lost if it is wrong, differ by an order of magnitude across asset classes. One constant would be either useless for a building or intolerable for a harvest.
| Asset class | Verification | Window | Why |
|---|---|---|---|
| Government / municipal bond | none needed | 2 days | The amount was fixed at issuance |
| Agricultural / commodity | VALUER on quantity, oracle on price |
7 days | Short cycle; price is observable |
| Real estate / infrastructure | VALUER |
30 days | Unique asset, no comparable, largest sum |
| SME equity / revenue share | GOVERNANCE |
30 days | Earnouts and non-cash consideration |
A bond needs no oracle at all, and this is worth dwelling on because it is the sovereign case. The redemption schedule — par plus coupon, on a date — was fixed when the vehicle was created. There is nothing to appraise. Verification degenerates to a question the chain can answer by itself: did the expected amount arrive in the vault? No valuer, no attestors, no vote, no trusted party between the issuer and the holders. A state issuing a bond on this chain is asking its citizens to trust arithmetic rather than a person, and that is the strongest thing the module can offer anybody.
The commodity case leans on machinery that already exists: x/oracle carries
validator-voted rates, so the price side is a read. Only the quantity — how
many tonnes actually sold — needs a human, which is a far narrower thing to
attest than a valuation.
Real estate gets the longest window for the same reason it gets the valuer: the asset is unique, there is no comparable to check the figure against, and the sum is the largest in the vehicle's life. Thirty days is short next to the years the vehicle ran, and it is the only period in which a wrong number can still be caught.
The dispute bond
A challenge window with no cost invites nuisance disputes; a flat bond is trivial for a large fraud to post and prohibitive for a small holder to raise. So the bond scales with the vehicle, not with the disputer: a fixed basis-point fraction of the reported sale price, refunded in full if the dispute succeeds.
A failed dispute forfeits the bond to the vault, never to the issuer. Paying it to the issuer would give them a reason to provoke weak challenges, and an incentive to be opaque enough to attract them.
The vault holds, the title records
Payments land in a vault keyed to the asset, never in the NFT holder's account. That separation is what keeps a change of sponsor, custodian or operator a non-event for shareholders: whoever holds title inherits an obligation to pay in, not a balance they could walk away with.
Five defects, and the upgrade that closed them
Everything above was written, reviewed and merged before any of it had been driven end to end. On 2026-08-27 the sale pipeline got its first test and five defects fell out of it, four of which lost money silently.
They are recorded here rather than quietly fixed, because the guide argued a set of protections that were, for the module's entire life until then, not there.
| What it was | Why nobody noticed | |
|---|---|---|
| No shareholder could ever be paid | SendRestrictionFn settles both sides of a share transfer, which the income index requires. It was written, commented, and registered nowhere — app.go appended only the enforcement restriction. So no transfer settled, no position was created by one, and every entitlement read zero however much the vault held. |
A repository-wide search found the function referenced by nothing at all, not even a test. |
| A holder who never transferred was paid nothing | Fractionalise created the vault and no position for the owner, so Settle treated them as a first-time holder on the way out and started them at an index that had already moved. |
Any transfer settles both sides, so the ordinary issue-then-distribute path created the position by accident. |
AttestSale never checked who was attesting |
ErrNotAttestor was registered as code 17 and returned from nowhere, and Collection carried no register to check against. A sponsor met any threshold with fresh addresses at the cost of the gas. |
The one guard between a shareholder and a falsified sale price was decorative, and this guide's own "the sale price is the attack" section described it as if it worked. |
FinaliseSale had no caller |
A keeper method nothing invoked — no message, no EndBlocker, not one test. No asset could reach REALISED, and Redeem requires it. Every fractionalised vehicle was a one-way door. |
Second dead load-bearing function in this module, and both were found the same way: by driving the thing rather than reading it. |
DisputeSale said the window was open when it had closed |
It returned ErrStillInWindow after the window had expired. Cosmetic beside the other four, and the kind of message that sends somebody looking in the wrong place for an hour. |
Nothing exercised the post-window path. |
MsgFinaliseSale is the caller FinaliseSale never had, and it is
permissionless: a crank only the sponsor could turn is one the sponsor can
decline to turn.
Verified on the chain
All five shipped in the income-that-arrives upgrade, applied at height
119,900 on yamale-devnet-2 — read back from
cosmos.upgrade.v1beta1.Query/AppliedPlan rather than from the proposal.
The fix is visible in the difference between two vehicles in the cd-terres
collection, read on 2026-08-31:
Asset 2, tok/2/KIN518 |
Asset 3, tok/3/KIN777 |
|
|---|---|---|
| Fractionalised | before the upgrade | after it (parcel registered at height 119,926) |
| Vault holds | 72,000,000 uyml |
40,000,000 uyml |
| Owed to the 750,000-share holder | 0 | 18,000,000 uyml |
Both are 1,000,000 shares at holder_share_bps = 6000. On asset 3, 40 YML into
the vault is 24 YML to holders, of which 75% is 18 YML — which is what
Query/Entitlement says. On asset 2 the same query says nothing is owed to
anybody, against a vault holding 72 YML.
What the upgrade deliberately does not fix, and it is the reason asset 2 still reads zero: a vehicle fractionalised before that height has no seeded positions, and income already paid into its vault stays owed to nobody. Reconstructing who held what across funding events that were never recorded is not something a handler can honestly do, so the pre-existing vehicle keeps its stranded balance and the demonstration uses one minted afterwards. There is no store migration.
One defect from the same investigation is still open and is in
gaps.md: FinaliseSale puts the whole reported
price through the income index while moving no coins, and FundVault accrues
them a second time on the way in. The fix is a decision rather than a mechanism —
either finalising pulls the price from the reporter, which makes a reported price
binding, or funding stops accruing once a sale is reported — and
TestAVehicleCanBeExited asserts the broken behaviour deliberately, so whoever
decides it will see the test fail and have to look.
State
Offerings Map[uint64, Offering] // id -> record
Subscriptions Map[Pair[uint64, string], Coin] // (offering, subscriber)
Distributions Map[Pair[uint64, uint64], Dist] // (offering, height)
Claims KeySet[Triple[uint64, uint64, string]]
NextOfferingID Sequence
Offering ids start at 1. Zero is indistinguishable from an unset proto field, and that has already cost this project once.
Subscriptions is keyed by a pair so a subscriber's commitment to one offering
is a single read. Listing every subscriber to an offering is a prefix scan;
listing every offering one account joined is not, and belongs in the indexer
rather than in a second index nobody maintains.
Messages
| Message | Signer | Effect |
|---|---|---|
MsgCreateOffering |
anyone | Writes a PENDING record. Holds no funds. |
MsgApproveOffering |
gov only | PENDING -> OPEN. |
MsgRejectOffering |
gov only | PENDING -> REJECTED, terminal. |
MsgSubscribe |
anyone | Moves funds to the module account. |
MsgWithdrawSubscription |
subscriber | Only while OPEN. |
MsgSettleOffering |
anyone | Crank. Mints or refunds per mode. |
MsgDistribute |
issuer | Funds a distribution at the current height. |
MsgClaim |
holder | Claims one distribution. |
MsgSubscribe carries min_tokens_out. The price is fixed at creation so the
computation is not state-dependent, but pro-rata allocation under
keep-what-you-raise is, and a user signs against a state that has moved.
And the vehicle half, where the signer column is the whole argument:
| Message | Signer | Effect |
|---|---|---|
MsgCreateCollection |
gov only | A collection, with its verification mode, threshold and window. |
MsgSetCollectionAuthority |
gov only | Who may mint title into it. |
MsgSetCollectionAttestors |
gov only | Who may attest a sale. Replaces the register; does not revisit attestations already made. |
MsgResolveDispute |
gov only | Back to ACTIVE after a successful challenge, never automatically. |
MsgMintAsset |
the collection's authority | Records title, naming its recipient. There is no self-mint-then-transfer path. |
MsgFractionalise |
title holder | Fixes the supply and holder_share_bps, and seeds the first holder's position. |
MsgTransferAsset |
title holder | |
MsgFundVault |
title holder | Income arrives; cumulative_per_token rises. |
MsgReportSale |
title holder | Opens the challenge window. The largest number in the vehicle's life, and the one it cannot verify. |
MsgAttestSale |
an appointed attestor | Not permissionless, which is where it used to sit. |
MsgDisputeSale |
anyone | Stakes the collection's bond in the same block. |
MsgFinaliseSale |
anyone | REPORTED -> REALISED, opening redemption. Permissionless on purpose. |
MsgClaim |
holder | Takes accrued income without giving up the shareholding. |
MsgRedeem |
holder | Burns and pays out in one step. The burn is the claim. |
Two are deliberately absent from the CLI. UpdateParams because it is authority
gated. ResolveDispute because it is the one message that can overwrite a price
the market has already been told, and it belongs to the collection's authority
acting on a finding — not to a flag somebody can reach for while a dispute is
still an argument.
Dependencies
x/amm— pools fortok/denoms follow automatically from free transfer. No new work, but the distribution exclusion above depends on the pool being a module account.x/oracle— the appointed-valuer path already built for NFT appraisal is what real estate and infrastructure offerings need for periodic valuation. Nothing new to build; it needs wiring.x/alias— issuers should be identifiable by user ID, not raw address, everywhere a subscriber sees them.- No lending, no liquidation. Nothing here is collateralised. If that changes, liquidation is a separate module with its own keeper incentives and bad-debt policy, and it should not be smuggled in here.
What the three open questions turned out to be
This section used to list three things to decide before building. The module is built, so here is what each became — kept rather than deleted, because the one that is still open is easier to see beside the two that closed.
The distribution snapshot mechanism became no snapshot at all. The worry was
that paying holders from a snapshot lets somebody buy just before it and sell
just after, collecting a whole period's income for a moment's holding. Rather
than trying to place the snapshot where that is hardest, the vault carries a
cumulative-income-per-token index, and each holder carries the index as of the
last time their balance moved. What accrues to them is the difference multiplied
by what they held across it, settled on every transfer. Two blocks of holding
earn two blocks of income. There is no moment to time, so there is nothing to
snipe, and Query/Entitlement can answer exactly what one account may take right
now — which a snapshot scheme cannot do between snapshots.
A holder seen for the first time starts at the current index, not at zero. Starting them at zero would credit them the asset's entire history, paying them for a period in which they held nothing.
MaxHolders became nothing. It is not a chain parameter and not a
per-offering field, because neither answer was any good: a chain-wide cap is a
number no country agreed to, and a per-offering cap is one the issuer picks,
which makes it a cap on nobody. Where a jurisdiction limits how many people may
hold a private offering, that limit belongs to the registry that admitted the
vehicle, expressed as a restriction on the parcel, and x/land already carries
restrictions as data precisely because this kind of rule differs by country.
The issuer bond is still open, and it is still the gap it always was. The
dispute_bond_bps in a collection is staked by a challenger, not by the
issuer: it makes a frivolous challenge expensive, which is the opposite end of
the problem. There is nothing an issuer forfeits for taking the money and
delivering nothing, so the chain's guarantee still ends at settlement.
What partly covers it today, and it is worth being precise about how much:
ReportSale is checked by the collection's attestation threshold and challenge
window, so an issuer who reports a false price can be caught and disputed. An
issuer who reports nothing at all, and simply never realises the vehicle, is
caught by neither — the holders keep their tokens, the vault keeps whatever was
paid into it, and no message on this module compels an exit. That is a real
limitation and it should be read as one, not as an oversight.
Open
- The issuer bond above. Unresolved, and the only on-chain lever against an issuer who takes the money and does nothing.
- Nothing compels realisation. A closed-end vehicle with no end date is an open-ended one with worse liquidity, and the module does not currently carry a defined life even though this guide's own heading promises one.