Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source realm

Package amm is a minimal constant-product automated market maker for GRC20 token pairs, in a single file.

Readme View source

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.

Design study: moul/gno-contracts#135.

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, keyB string) string                 // the pool's LP token registry key
2AllowanceLP(keyA, keyB string, owner, spender address) int64
3TransferLP(cur realm, keyA, keyB string, to address, amount int64)
4ApproveLP(cur realm, keyA, keyB string, spender address, amount int64)
5TransferFromLP(cur realm, keyA, keyB string, from, to address, amount int64)

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:

1grc20reg.Transfer(0, cur, amm.LPToken(keyA, keyB), to, n)

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:

gno.land/r/moul/x/amm/v0 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.

Design study and full analysis: https://github.com/moul/gno-contracts/issues/135

Functions 15

func AddLiquidity

crossing Action
1func AddLiquidity(cur realm, keyA, keyB string, maxA, maxB int64) int64
source

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.

func AllowanceLP

Action
1func AllowanceLP(keyA, keyB string, owner, spender address) int64
source

AllowanceLP reports how much of owner's LP position spender may move.

func AmountOut

Action
1func AmountOut(amountIn, reserveIn, reserveOut int64) int64
source

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.

func ApproveLP

crossing Action
1func ApproveLP(cur realm, keyA, keyB string, spender address, amount int64)
source

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.

func LPToken

Action
1func LPToken(keyA, keyB string) string
source

LPToken returns the grc20reg key of the pool's LP token. Hand it to any realm that should read or move these positions without importing this one.

func Quote

Action
1func Quote(keyIn, keyOut string, amountIn int64) int64
source

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.

func RemoveLiquidity

crossing Action
1func RemoveLiquidity(cur realm, keyA, keyB string, shares int64) (int64, int64)
source

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.

func Render

1func Render(path string) string
source

Render lists every pool, or one pool's detail when path is a "keyA~keyB" pool id.

func Reserves

Action
1func Reserves(keyA, keyB string) (int64, int64)
source

Reserves returns the two reserves in the caller's argument order.

func SharesOf

Action
1func SharesOf(keyA, keyB string, owner address) int64
source

SharesOf returns owner's LP shares, i.e. their LP token balance.

func Swap

crossing Action
1func Swap(cur realm, keyIn, keyOut string, amountIn, minOut int64) int64
source

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.

func TotalShares

Action
1func TotalShares(keyA, keyB string) int64
source

TotalShares returns the pool's LP token total supply.

func TransferFromLP

crossing Action
1func TransferFromLP(cur realm, keyA, keyB string, from, to address, amount int64)
source

TransferFromLP spends an allowance the owner granted to the caller.

func TransferLP

crossing Action
1func TransferLP(cur realm, keyA, keyB string, to address, amount int64)
source

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)

Imports 9

Source Files 7