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 pure

Package pair is the whole behaviour of one constant-product AMM pair, as a pure package, so that a realm holding a si...

Readme View source

gno.land/p/moul/x/pair/v0

One constant-product AMM pair (x*y=k, 30 bps to the liquidity providers), as a pure package, so that the realm holding a pair can be twenty lines of glue.

This is the shared half of the instance-per-realm pattern: gno's answer to the Ethereum factory shape, where an ecosystem is a frontend over thousands of tiny identical contracts, one per token couple.

artifact what it is
p/moul/x/pair/v0 this package, the whole behaviour, deployed once
an instance realm ~20 lines: two constants, an init that registers, one-line re-exports. See r/moul/x/pairs/aaabbb/v0
r/moul/x/pairreg/v0 the registry every instance announces itself to, and the unified frontend over all of them
tools/pairgen the factory, which runs on your machine because gno has no on-chain deploy

What gno changes about the Ethereum pattern

  • No on-chain factory. vm/add_package is permanently denied to realm code, so an instance is an addpkg transaction signed by a human, priced in gas and a storage deposit. tools/pairgen fills the template and prints the command.
  • Spawning is still permissionless. Any address may deploy under gno.land/r/<its own g1 address>/** with no registered username, so anyone can run their own instance and land in the registry.
  • No clone trick needed. Ethereum copies bytecode or delegatecalls through an EIP-1167 proxy; an import stores this package once for every instance.
  • The registry sees live state. Instances register a *pair.Pair pointer, so one vm/qrender renders every instance's real reserves, with no indexer.
  • Nothing attests the code. No realm can read another package's source, so an instance's code can only be verified off chain, by diffing it against freshly generated output. Unforgeable identity, no code attestation: the mirror image of CREATE2.

Using it from an instance

 1var p *pair.Pair
 2
 3func init(cur realm) {
 4	p = pair.New(keyA, keyB, grc20reg.MustGet(keyA), grc20reg.MustGet(keyB))
 5	pairreg.Register(cross(cur), p)
 6}
 7
 8func Swap(cur realm, keyIn string, amountIn, minOut int64) int64 {
 9	return p.Swap(0, cur, keyIn, amountIn, minOut)
10}

Every state-changing method is shaped (_ int, rlm realm, ...). That is forced, not stylistic: a pure package cannot declare a crossing function (func F(cur realm, ...) fails to compile there), and the parameter cannot be named cur either. The realm value arrives non-crossing, which is exactly what is wanted: the frame stays the instance realm, so funds move to and from the instance's address and grc20's rlm.IsCurrent() spoof check still passes. p/moul/x/framelab/v0 is the 30-line probe that pins this property.

A pure package also cannot import a realm, so this one never resolves a token by path: the instance passes *grc20.Token handles in.

Economics, and what is deliberately absent

Reserves are stored fields, never derived from BalanceOf, which removes MINIMUM_LIQUIDITY, skim/sync and the first-depositor inflation attack in one decision. The price: tokens sent directly to an instance's address are permanently stuck. The first deposit mints shares = amountA with no sqrt; later deposits mint min(A-side, B-side) with floor division, so an off-ratio deposit rounds against the depositor. No oracle, no flash swap, no routing, no protocol fee, no governance.

All amounts are int64 and the fee denominator is 1000, so every reserve is capped at MaxReserve = MaxInt64/1000. Unusable with 18-decimal tokens; six to nine decimals is the practical band.

Full design study of the economics: gno-contracts#135.

When to copy this pattern, and when not to

Uniswap V4 moved from a pair-per-contract factory to a singleton, and PR #137 is the same AMM as one realm holding N pools. An AMM is therefore the contested case, kept here as a deliberate A/B.

Reach for instance-per-realm when instances have different owners, trust and lifecycles (a user's shop, a DAO, a game table). Keep one realm with N objects when instances are fungible parts of one shared network (liquidity, an order book, a global index).


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/p/moul/x/pair/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 pair is the whole behaviour of one constant-product AMM pair, as a pure package, so that a realm holding a single pair can be twenty lines of glue.

It is the shared half of the instance-per-realm pattern: deploy this once, then deploy one tiny realm per token couple, each owning its own state, its own funds and its own storage deposit, all running this identical code. The Ethereum shape it copies (a factory spawning one UniswapV2Pair per couple) needs EIP-1167 clones to make the duplication affordable; an import makes it free.

How an instance uses it

Example
 1var p *pair.Pair
 2
 3func init(cur realm) {
 4	p = pair.New(keyA, keyB, grc20reg.MustGet(keyA), grc20reg.MustGet(keyB))
 5	pairreg.Register(cross(cur), p)
 6}
 7
 8func Swap(cur realm, keyIn string, amountIn, minOut int64) int64 {
 9	return p.Swap(0, cur, keyIn, amountIn, minOut)
10}

Why the methods look like that

Every state-changing method is shaped (_ int, rlm realm, ...). The leading dummy is mandatory: gno rejects a function whose FIRST parameter is realm when it is declared in a pure package ("crossing function declared in non-realm package"), and the parameter cannot be named cur either. Same reason grc20's tellers are Transfer(_ int, rlm realm, ...). The realm value arrives non-crossing, which is exactly what is wanted: the frame stays the instance realm, so funds move to and from the INSTANCE's address, and rlm.IsCurrent() still holds for grc20's spoof check.

A pure package also cannot import a realm at all, so this package never resolves a token by path. The instance passes *grc20.Token handles in.

The economics, unchanged from the single-realm design

Constant product x*y=k with a 30 bps fee kept by the pool, reserves stored and never derived from BalanceOf (so a direct transfer to the realm is inert, and permanently stuck), first deposit minting shares equal to the token-A amount with no sqrt and no MINIMUM_LIQUIDITY burn, and every reserve capped so the int64 arithmetic cannot overflow. The reasoning behind each of those is in https://github.com/moul/gno-contracts/issues/135 and is not repeated here; this package is that design with one pool per realm instead of many pools in one realm.

Consequence worth repeating, because it bites: with a 1000x fee denominator on int64 amounts, this is unusable with 18-decimal tokens. Six to nine decimals is the practical band.

Constants 1

const feeNum, feeDen, MaxReserve

 1const (
 2	// feeNum/feeDen is the swap fee kept by the pool: 997/1000, i.e. 30 bps.
 3	feeNum = 997
 4	feeDen = 1000
 5
 6	// maxReserve is the largest reserve a pair will hold, in base units.
 7	//
 8	// Pricing computes den = reserveIn*feeDen + amountIn*feeNum in plain
 9	// int64 before handing off to the 128-bit mulDiv. Capping every reserve,
10	// and every post-swap reserve, at MaxInt64/feeDen makes that sum provably
11	// safe:
12	//
13	//	den < (reserveIn + amountIn) * feeDen <= maxReserve * feeDen
14	//	    = 9223372036854775000 <= MaxInt64 = 9223372036854775807
15	MaxReserve = math.MaxInt64 / feeDen // 9223372036854775
16)
source

Functions 2

func AmountOut

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 New

1func New(keyA, keyB string, tokA, tokB *grc20.Token) *Pair
source

New builds an empty pair from two grc20reg keys and the matching token handles, in canonical order. The caller (the instance realm) is responsible for resolving the handles, because a pure package cannot import the registry realm that holds them.

Types 1

type Pair

struct
1type Pair struct {
2	keyA, keyB  string
3	tokA, tokB  *grc20.Token
4	resA, resB  int64
5	totalShares int64
6	shares      *avl.Tree // address string -> int64
7}
source

Pair is one token couple and its liquidity. Fields are concrete by design: the registry realm holds a live pointer to this struct and reads it while rendering, so an interface or a func field here would let a hostile instance hand foreign code to the registry's frame.

keyA < keyB always holds; New canonicalises.

Methods on Pair

func AddLiquidity

method on Pair
1func (p *Pair) AddLiquidity(_ int, rlm realm, maxA, maxB int64) int64
source

AddLiquidity deposits up to maxA of token A and maxB of token B and mints LP shares to the caller. On an existing pair the deposit is trimmed to the current reserve ratio, so pass the amounts you are willing to spend, not the amounts you insist on spending.

The caller must first Approve the INSTANCE realm's address on both tokens. Returns the shares minted.

func ID

method on Pair
1func (p *Pair) ID() string
source

ID is the canonical identifier of the couple, "keyA~keyB". The separator is "~" and not "|": a "|" inside a markdown table cell breaks the row.

func Keys

method on Pair
1func (p *Pair) Keys() (string, string)
source

Keys returns the two grc20reg keys in canonical order.

func Providers

method on Pair
1func (p *Pair) Providers() int
source

Providers returns how many addresses hold shares.

func Quote

method on Pair
1func (p *Pair) Quote(keyIn string, amountIn int64) int64
source

Quote prices amountIn of keyIn against the live reserves. It is the number Swap would return right now, which is not a promise about the next block.

func RemoveLiquidity

method on Pair
1func (p *Pair) RemoveLiquidity(_ int, rlm realm, shares int64) (int64, int64)
source

RemoveLiquidity burns shares held by the caller and returns the proportional amounts of both tokens, in (A, B) order.

Burning the entire share supply pays out the whole reserve, so the last provider out leaves nothing unclaimable behind and the pair can be reseeded at a fresh price.

func Render

method on Pair
1func (p *Pair) Render(path string) string
source

Render is the instance realm's whole page: what the couple is, what it holds, and how to trade it.

func Reserves

method on Pair
1func (p *Pair) Reserves() (int64, int64)
source

Reserves returns the two reserves in canonical order.

func SharesOf

method on Pair
1func (p *Pair) SharesOf(owner address) int64
source

SharesOf returns owner's LP shares.

func Swap

method on Pair
1func (p *Pair) Swap(_ int, rlm realm, keyIn string, amountIn, minOut int64) int64
source

Swap sells amountIn of keyIn for the other token and aborts unless at least minOut comes back. The caller must first Approve the instance realm's address on keyIn.

minOut is the only protection against being sandwiched or against the pair moving between quoting and execution. Pass a real bound; passing 0 means accepting any price at all.

func Symbols

method on Pair
1func (p *Pair) Symbols() (string, string)
source

Symbols returns the two token symbols in canonical order.

func TotalShares

method on Pair
1func (p *Pair) TotalShares() int64
source

TotalShares returns the LP share supply.

Imports 7

Source Files 4