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.
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.
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:
🧪 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.
1const( 2// feeNum/feeDen is the swap fee kept by the pool: 997/1000, i.e. 30 bps. 3feeNum=997 4feeDen=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 provably11// safe:12//13// den < (reserveIn + amountIn) * feeDen <= maxReserve * feeDen14// = 9223372036854775000 <= MaxInt64 = 922337203685477580715MaxReserve=math.MaxInt64/feeDen// 922337203685477516)
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.
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.
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.
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.
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.
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.