amm: a constant-product AMM whose LP positions are real GRC20 tokens
A constant-product market maker for GRC20 pairs, many pools in one realm,
x*y=k with a 30 bps fee that stays with the liquidity providers. A liquidity
position is a GRC20 token, minted per pool and registered with
r/nt/grc20reg, so a position can be
transferred, approved, and priced by anything else on the chain.
The ledger-row design this replaced, and what it cost to leave it
The first cut of this realm kept a position as a row in a private avl.Tree.
That is the smaller thing, and it is what the realm shipped with until the
measurements below said the trade was worth taking. Both were built and run, so
the comparison is measured rather than argued, and the earlier shape is
recoverable from #137.
a private ledger row
a registered GRC20
transferable
no
yes
approvable / usable as collateral
no
yes
readable by another realm
no
yes, via grc20reg.Get(key)
the realm must expose
nothing extra
4 wrappers + LPToken
pool creation also does
nothing
mint a token, write a registry entry
allowance race surface
none
the standard GRC20 one
Nothing about pricing, reserves, rounding or the guards moved: the ledger-row
version's amm_test.gno runs unmodified against this one, only the pkgpath
string differs. What moved is where a share lives.
Four identical operations, one --- GAS: figure each, from the same
gas_test.gno run against both. Fixture funding is measured separately so it
does not pollute the comparison:
operation
ledger row
GRC20
delta
seed (create pool + first deposit)
1 716 207
2 213 407
+497 200 (+29.0%)
swap
1 459 531
1 459 531
0 (+0.0%)
join (second provider)
1 731 048
1 789 306
+58 258 (+3.4%)
exit (full burn)
1 433 236
1 518 298
+85 062 (+5.9%)
ledger row
GRC20
amm.gno, total lines
506
589
amm.gno, code lines
333
359
exported functions
10
15
The shape of that is the interesting part. Swapping is unaffected to the
gas unit, because the hot path never touches share accounting: it reads two
reserves, prices, moves two token balances, writes two reserves. The whole
premium is paid where positions are created and destroyed. Pool creation
carries it almost entirely, once, as a fixed setup cost: minting the LP token
and registering it. Per-provider operations pay 3 to 6 percent.
Read the other way: transferable LP positions cost a one-off ~0.5M gas per
pool and ~5% on liquidity operations, and nothing at all on trading. That is
why they are the default here, and why Uniswap V2 pairs are ERC20s.
The LP API
1LPToken(keyA,keyBstring)string// the pool's LP token registry key2AllowanceLP(keyA,keyBstring,owner,spenderaddress)int643TransferLP(currealm,keyA,keyBstring,toaddress,amountint64)4ApproveLP(currealm,keyA,keyBstring,spenderaddress,amountint64)5TransferFromLP(currealm,keyA,keyBstring,from,toaddress,amountint64)
The four wrappers exist because the LP token lives in this realm: a signing
user has no token realm of its own to call, the way they would for any other
GRC20. A realm holding LP does not need them and can move its own balance
through the registry:
ApproveLP carries the usual GRC20 approve race: an allowance lowered from a
non-zero value can be spent at both the old and the new figure under unlucky
ordering. Set it to 0 first.
SharesOf and TotalShares are lp.BalanceOf and lp.TotalSupply;
AddLiquidity, RemoveLiquidity, Swap, AmountOut, Quote, Reserves,
PoolCount and Render are unchanged by the LP decision.
LP token naming
One token per pool, symbol LP<n> from a never-reset counter, name
AMM LP <symA>/<symB>, decimals mirroring token A (the LP unit is token A at
seed time). The symbol is a counter and not the pair because grc20 caps a
symbol at 11 characters, which LP- plus two 11-character symbols would blow
straight past; the readable pair goes in the name, which allows 64.
Never resetting the counter is what keeps grc20reg's
one-token-per-realm-and-symbol rule satisfiable forever: a drained pool keeps
its LP token and identity, and reseeding reuses it rather than minting a
second token under a symbol already taken.
Warnings
Not an oracle. The reserve ratio is a spot price any trader can move
inside one transaction. Nothing should price off this realm.
minOut is your only slippage protection, a pool is only as honest as
its two tokens, and none of this is audited.
The LP PrivateLedger never leaves this realm. It is the minting
authority; exporting it, even indirectly, would let anyone mint positions
against real reserves.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Overview
Package amm is a minimal constant-product automated market maker for GRC20 token pairs, in a single file.
It holds many pools in one realm. A pool is created implicitly by its first liquidity deposit and is addressed by the two tokens' grc20reg keys, in either order. Swaps follow x*y=k with a 30 bps fee that stays in the pool and therefore accrues to the liquidity providers.
What it is not
Not an oracle: the reserve ratio is a spot price that any trader can move within a single transaction. Nothing should price off this realm. Not audited. Not a router: one hop, one pool, no path finding. Not a place to park value.
Design notes worth knowing before calling it
Reserves are stored fields and are NEVER derived from BalanceOf. That is the single decision that removes Uniswap V2's MINIMUM_LIQUIDITY burn, its skim/sync pair, and the first-depositor share-inflation attack in one go: sending tokens straight to this realm's address changes no reserve, no price and no share value. The price of that is blunt and worth stating: tokens sent directly to the realm address are PERMANENTLY STUCK.
The first deposit mints shares equal to the token-A amount rather than sqrt(A*B). The geometric mean is cosmetic, every later operation uses only ratios, and dropping it removes an integer square root over a 128-bit product. Later deposits mint min(A-side, B-side) with floor division on both, so an off-ratio deposit always rounds against the depositor.
v1 vs v0: LP shares are a real GRC20 token
v0 tracks LP positions in a private avl ledger: smallest possible, but a position can only be held by the address that opened it and is invisible to every other contract. v1 mints one GRC20 token per pool instead, holds its PrivateLedger, and registers it with grc20reg. A position is then an ordinary fungible token: transferable, approvable, usable as collateral, and readable by any realm that knows the registry key.
The cost is honest and measurable. Pool creation now also mints a token and writes a registry entry. Positions gain an allowance surface, and with it the classic GRC20 approve race. And because MsgCall cannot pass a realm argument, this realm has to re-export Transfer/Approve/TransferFrom/ Allowance wrappers over the LP token for signing users, four functions that v0 does not need at all. Another REALM can skip them and move its own LP balance through grc20reg directly.
All amounts are int64, as GRC20 mandates, and there is no 256-bit type in reach. Pricing therefore runs through a 128-bit mulDiv (math/bits), and every reserve is capped at maxReserve so that the plain int64 parts of the formula cannot overflow. See the comment on maxReserve for the arithmetic, and note the consequence: this AMM is unusable with 18-decimal tokens. Six to nine decimals is the practical band.
AddLiquidity deposits up to maxA of keyA and maxB of keyB and mints LP shares to the caller. It creates the pool if this is its first deposit, in which case the caller's amounts set the starting price and the whole of maxA and maxB is taken.
On an existing pool the deposit is trimmed to the current reserve ratio: only one side is consumed in full and the remainder of the richer side is left untouched, so pass the amounts you are willing to spend, not the amounts you insist on spending.
The caller must first Approve this realm's address on BOTH tokens for at least the amounts that will be taken. Returns the shares minted.
AmountOut is the pricing function, fee included, as a pure function of the two reserves. Exported so a caller can quote off-chain against reserves it already holds, and so the arithmetic is testable on its own.
ApproveLP lets spender move up to amount of the caller's LP position.
Same approve race as any GRC20: an allowance changed from a non-zero value can be spent at both the old and the new one if the holder is unlucky with ordering. Set it to 0 first when lowering it.
Quote prices amountIn of keyIn against the pool's live reserves. It is the number Swap would return right now, which is not a promise about the next block.
RemoveLiquidity burns shares held by the caller and returns the proportional amounts of both tokens, in the caller's (keyA, keyB) argument order.
Burning the pool's entire share supply pays out the whole reserve, so the last provider out leaves nothing unclaimable behind and the pool can be reseeded at a fresh price.
Swap sells amountIn of keyIn for keyOut and aborts unless at least minOut comes back. The caller must first Approve this realm's address on keyIn.
minOut is the only protection against being sandwiched or against the pool moving between quoting and execution. Pass a real bound; passing 0 means accepting any price at all.
TransferLP moves amount of the caller's LP position to `to`.
The LP token lives in this realm, so a signing user cannot reach it through the token's own entry points the way they would for any other GRC20: there are none. These four wrappers are that entry point. A REALM holding LP does not need them and can go through grc20reg instead:
Example
1grc20reg.Transfer(0, cur, amm.LPToken(keyA, keyB), to, n)