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 grc20wrap builds derived GRC20 tokens on top of existing ones.

Readme View source

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

Build a new GRC20 on top of one you do not control. Vault escrows an existing token and issues its own against it; Basket does the same over several at once. A Policy decides the exchange rate in both directions and whether the wrapped token may move, which is the whole personality of a wrapper.

 1import (
 2    "gno.land/p/moul/x/grc20wrap/v0"
 3    "gno.land/p/nt/grc20/v0"
 4)
 5
 6// In your realm, over any *grc20.Token you can reach (an import, or a
 7// gno.land/r/nt/grc20reg/v0 lookup):
 8v := grc20wrap.NewVault(under, grc20wrap.Pool{}, "Pooled FOO", "pFOO", 4, 0, cur)
 9
10// The holder approves YOUR realm's address on the underlying first, through
11// the underlying realm's own entry point. Then:
12shares, err := v.Wrap(0, cur, holder, 1000)   // escrow 1000, mint shares
13out, err := v.Unwrap(0, cur, holder, shares)  // burn shares, release escrow
14err = v.Donate(0, cur, patron, 500)           // no mint: every share is worth more

The five policies

policy wrapped token behaves like
OneToOne a 1:1 custody receipt
Ratio{Num, Den} the same token re-denominated ({1000, 1} adds three decimals)
Soulbound{Base} a badge: wrap and unwrap freely, never transferable
Pool a share of the escrow; Donate pays every holder at once
Fee{Base, WrapBPS, UnwrapBPS} a haircut left behind, which over Pool pays whoever stays

They compose: Fee{Base: Pool{}, UnwrapBPS: 100} is a pool with a 1% exit fee. Writing your own means three methods and a name; embed OneToOne and override only what differs.

Basket: one token backed by two

1b := grc20wrap.NewBasket(
2    []*grc20.Token{red, blue}, []int64{1, 2},
3    "Purple", "PURPLE", 4, 0, cur,
4)
5err := b.Fuse(0, cur, holder, 100)   // escrow 100 RED + 200 BLUE, mint 100 PURPLE
6err = b.Defuse(0, cur, holder, 40)   // burn 40, hand back 40 RED + 80 BLUE

Proportions are fixed, and the meta-token is only ever minted against the real thing, so it cannot drift from its backing or be arbitraged. It has no price oracle and no rebalancing, because both would mean valuing the legs.

Three things worth knowing before using it

Custody is an allowance, never a privilege. Escrow sits at the host realm's address and moves through grc20.RealmTeller, which grc20 binds eagerly to that address. A wrapper can only take what a holder approved for it, and can only ever spend its own realm's balance.

Errors are returned before anything moves, panics after. Wrapping is two ledger writes on two different tokens and gno has no rollback short of a panic, so each method validates first and returns an error while the world is still untouched, then panics if a write fails past the point of no return. Aborting the transaction is the only atomicity available.

A policy veto binds users, not realms. Vault.Move is the only path a signing account has, because MsgCall cannot build the realm argument grc20's tellers require. A realm holding the wrapped token can always call grc20.RealmTeller and move its own balance. Soulbound means "no user can pass it on", not "it can never move".

Live demo: r/moul/x/grc20wrapdemo · render it at /r/moul/x/grc20wrapdemo/v0.


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/grc20wrap/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 grc20wrap builds derived GRC20 tokens on top of existing ones.

Two shapes:

  • Vault wraps ONE underlying token. It escrows underlying units at the host realm's address and issues its own GRC20 against that escrow. A Policy sets the exchange rate in both directions and may veto a move of the wrapped token, which is where a wrapper stops being plumbing and becomes a pattern: 1:1 custody, a denomination change, a non-transferable receipt, a share in a growing pool.
  • Basket wraps SEVERAL. One meta-token is minted against fixed parts of every leg and splits back into them on redemption.

Neither type holds chain state of its own beyond the GRC20 ledger it creates, so a realm keeps them wherever it likes.

How custody works

Escrow lands at the HOST REALM's address. A vault moves it through grc20.RealmTeller, which binds eagerly to that address, so a vault can only ever spend what its own realm holds. Pulling a user's tokens in therefore takes the ordinary allowance route: the user approves the host realm's address on the underlying token, through that token realm's own entry point, and the vault draws on the allowance as itself.

Reverting, not returning

Wrapping is two ledger writes on two different tokens, and gno has no rollback short of a panic: a returned error does not undo what already ran. Every method here therefore validates while nothing has moved and returns an error, then panics if a write fails after the point of no return. Aborting the transaction is the only atomicity available.

What a Policy can and cannot enforce

A Policy veto binds end users, not realms. Moving the wrapped token through Vault.Move is the only path a signing account has, because MsgCall cannot build the realm argument grc20's tellers require. Another realm holding the wrapped token can always call grc20.RealmTeller on it and move its OWN balance. Soulbound means "no user can pass it on", not "it can never move".

Live demo: gno.land/r/moul/x/grc20wrapdemo/v0.

Variables 1

var ErrNilToken, ErrInvalidAmount, ErrDust, ErrUnbacked, ErrSoulbound, ErrOverflow, ErrBadRatio, ErrBadBPS, ErrBadLegs, ErrShortBalance, ErrShortAllowance

 1var (
 2	ErrNilToken       = errors.New("grc20wrap: nil underlying token")
 3	ErrInvalidAmount  = errors.New("grc20wrap: amount must be positive")
 4	ErrDust           = errors.New("grc20wrap: amount rounds to zero")
 5	ErrUnbacked       = errors.New("grc20wrap: payout exceeds this vault's escrow")
 6	ErrSoulbound      = errors.New("grc20wrap: soulbound, the wrapped token cannot change hands")
 7	ErrOverflow       = errors.New("grc20wrap: arithmetic overflow")
 8	ErrBadRatio       = errors.New("grc20wrap: ratio terms must be positive")
 9	ErrBadBPS         = errors.New("grc20wrap: basis points must be within 0..10000")
10	ErrBadLegs        = errors.New("grc20wrap: a basket needs at least two legs, each with a positive part")
11	ErrShortBalance   = errors.New("grc20wrap: insufficient balance on a leg")
12	ErrShortAllowance = errors.New("grc20wrap: insufficient allowance on a leg")
13)
source

Functions 2

func NewBasket

1func NewBasket(tokens []*grc20.Token, parts []int64, name, symbol string, decimals int, id seqid.ID, rlm realm) *Basket
source

NewBasket issues a meta-token over tokens[i], escrowing parts[i] units of each per meta unit.

Both slices must be the same length, hold at least two entries, name no token twice, and every part must be positive. As with NewVault, pass the owning realm's own cur: escrow lands at that realm's address.

func NewVault

1func NewVault(under *grc20.Token, pol Policy, name, symbol string, decimals int, id seqid.ID, rlm realm) *Vault
source

NewVault issues a new GRC20 backed by units of under.

Call it from the realm that will own the vault, forwarding that realm's own cur: both the teller binding and the new token's origRealm are taken from it and cannot be forged afterwards. id disambiguates several tokens minted by the same realm - allocate it from one persistent seqid.ID.

A nil pol means OneToOne.

Types 8

type Basket

struct
1type Basket struct {
2	legs []*leg
3	home address
4	tok  *grc20.Token
5	led  *grc20.PrivateLedger
6}
source

Basket issues ONE meta-token over several underlying GRC20s at fixed proportions. Minting a meta unit escrows every leg's `per` amount; redeeming hands all of them back.

A two-leg basket is the interesting one: it makes a pair tradable, quotable and transferable as a single object, without a price oracle, an AMM or any notion of what the legs are worth. The basket never values anything - it only ever swaps a fixed bundle for a receipt and back, so it cannot be arbitraged or drained. What it CAN do is let the market price the bundle, which is exactly how an index token works.

Proportions are fixed at creation. Rebalancing would mean revaluing the legs, which needs a price, which is a different contract.

Methods on Basket

func Allow

method on Basket
1func (b *Basket) Allow(owner, spender address, amount int64) error
source

Allow sets `spender`'s allowance over `owner`'s meta balance.

func Defuse

method on Basket
1func (b *Basket) Defuse(_ int, rlm realm, to address, units int64) error
source

Defuse burns `units` of the meta token held by `to` and returns every leg's share of the escrow to it.

func Fuse

method on Basket
1func (b *Basket) Fuse(_ int, rlm realm, from address, units int64) error
source

Fuse escrows per*units of every leg from `from` and mints `units` of the meta token to it.

`from` must have approved b.Home() on EVERY leg first. The whole basket is priced and checked before the first transfer, so a caller short on leg two gets an error with leg one untouched.

func Home

method on Basket
1func (b *Basket) Home() address
source

Home is the escrow account: the host realm's address.

func Leg

method on Basket
1func (b *Basket) Leg(i int) (*grc20.Token, int64, int64)
source

Leg returns the i-th component: its token, the units escrowed per meta unit, and the units escrowed so far.

func Legs

method on Basket
1func (b *Basket) Legs() int
source

Legs is the number of components.

func Move

method on Basket
1func (b *Basket) Move(from, to address, amount int64) error
source

Move transfers meta units between two accounts.

func MoveFrom

method on Basket
1func (b *Basket) MoveFrom(spender, owner, to address, amount int64) error
source

MoveFrom spends `spender`'s allowance over `owner`'s meta balance.

func Solvent

method on Basket
1func (b *Basket) Solvent() bool
source

Solvent reports whether every leg's escrow account still covers what the basket recorded taking in.

func Summary

method on Basket
1func (b *Basket) Summary() string
source

Summary renders the basket as a markdown block.

func Token

method on Basket
1func (b *Basket) Token() *grc20.Token
source

Token is the meta-token this basket issues.

type Fee

struct
1type Fee struct {
2	Base      Policy
3	WrapBPS   int64
4	UnwrapBPS int64
5}
source

Fee takes a basis-point haircut in both directions on top of Base (nil means OneToOne). 100 BPS is 1%.

The haircut is not paid to anyone: it stays escrowed. Over a plain base that only strands value, so Fee is meant to sit over Pool, where the stranded units raise what every remaining share redeems for. The wrapper then pays its holders out of its own turnover, and paying twice - in and out - costs more than holding.

Methods on Fee

func CanMove

method on Fee
1func (f Fee) CanMove(v *Vault, from, to address, amount int64) error
source

func Name

method on Fee
1func (f Fee) Name() string
source

func UnwrapRate

method on Fee
1func (f Fee) UnwrapRate(v *Vault, in int64) (int64, error)
source

func WrapRate

method on Fee
1func (f Fee) WrapRate(v *Vault, in int64) (int64, error)
source

type OneToOne

struct
1type OneToOne struct{}
source

OneToOne is the boring wrapper: one underlying unit in, one wrapped unit out, and no restriction on who may hold the result. Useful on its own as a pure custody receipt, and as the embedded base of every policy below.

Methods on OneToOne

func CanMove

method on OneToOne
1func (OneToOne) CanMove(v *Vault, from, to address, amount int64) error
source

func Name

method on OneToOne
1func (OneToOne) Name() string
source

func UnwrapRate

method on OneToOne
1func (OneToOne) UnwrapRate(v *Vault, in int64) (int64, error)
source

func WrapRate

method on OneToOne
1func (OneToOne) WrapRate(v *Vault, in int64) (int64, error)
source

type Policy

interface
 1type Policy interface {
 2	// WrapRate returns how many wrapped units `in` underlying units mint.
 3	// It is called BEFORE the escrow moves, so v.Held() excludes `in`.
 4	WrapRate(v *Vault, in int64) (int64, error)
 5
 6	// UnwrapRate returns how many underlying units `in` wrapped units
 7	// release. It is called before the burn, so v.Supply() includes `in`.
 8	UnwrapRate(v *Vault, in int64) (int64, error)
 9
10	// CanMove vetoes a transfer of the wrapped token between two accounts.
11	CanMove(v *Vault, from, to address, amount int64) error
12
13	// Name labels the policy in a catalogue. Short, lowercase.
14	Name() string
15}
source

Policy is a wrapper's personality. It sets the exchange rate in both directions and can veto a move of the wrapped token.

Every method receives the vault, so a policy can price against live state - escrow, supply, custody - instead of a frozen constant. Embed OneToOne to write a policy that only overrides what it cares about.

type Pool

struct
1type Pool struct {
2	OneToOne
3}
source

Pool prices in shares instead of units: the wrapped token is a claim on a fraction of the escrow rather than on a fixed amount.

Example
1wrap:   shares = in * supply / held      (1:1 while the pool is empty)
2unwrap: units  = in * held  / supply

Nothing changes until someone calls Vault.Donate, which adds escrow without minting shares. Every outstanding share is then worth more, permanently and for everyone at once. That is the whole yield-bearing-token pattern in two lines of arithmetic: a fee sink, a staking reward, an airdrop to holders, all the same operation.

Two honest caveats:

  • Division truncates, so a share is always worth marginally less than its exact fraction. The remainder stays in the pool, which is the safe direction: a vault can never promise more than it holds.
  • A first depositor can wrap one unit, donate a large amount, and make every later deposit smaller than one share round to zero in their favour (the ERC-4626 inflation attack). Seed the pool at creation, or keep the wrapped token's decimals well above the underlying's, before using this anywhere real.

Methods on Pool

func Name

method on Pool
1func (Pool) Name() string
source

func UnwrapRate

method on Pool
1func (Pool) UnwrapRate(v *Vault, in int64) (int64, error)
source

func WrapRate

method on Pool
1func (Pool) WrapRate(v *Vault, in int64) (int64, error)
source

type Ratio

struct
1type Ratio struct {
2	OneToOne
3	Num int64
4	Den int64
5}
source

Ratio re-denominates: Num wrapped units per Den underlying units. Both terms must be positive.

Ratio{Num: 1000, Den: 1} turns one underlying unit into a thousand wrapped ones, which is how you give a token three more decimals without touching the realm that issued it. The reverse direction divides, so a redemption that rounds to zero is refused (ErrDust) rather than silently burning value: with Num=1000, unwrapping 999 wrapped units returns nothing and is rejected.

Methods on Ratio

func Name

method on Ratio
1func (r Ratio) Name() string
source

func UnwrapRate

method on Ratio
1func (r Ratio) UnwrapRate(v *Vault, in int64) (int64, error)
source

func WrapRate

method on Ratio
1func (r Ratio) WrapRate(v *Vault, in int64) (int64, error)
source

type Soulbound

struct
1type Soulbound struct {
2	Base Policy
3}
source

Soulbound wraps another policy and refuses every transfer of the wrapped token. Rates are whatever Base says; a nil Base means OneToOne.

The result is an account-bound receipt: anyone can wrap into it and anyone can unwrap back out, but the wrapped unit itself never changes hands. That makes it a membership badge, a proof of deposit, or a vote weight that cannot be rented - while the underlying stays fully liquid one step away.

The veto binds signing users only; see the package doc.

Methods on Soulbound

func CanMove

method on Soulbound
1func (s Soulbound) CanMove(v *Vault, from, to address, amount int64) error
source

func Name

method on Soulbound
1func (s Soulbound) Name() string
source

func UnwrapRate

method on Soulbound
1func (s Soulbound) UnwrapRate(v *Vault, in int64) (int64, error)
source

func WrapRate

method on Soulbound
1func (s Soulbound) WrapRate(v *Vault, in int64) (int64, error)
source

type Vault

struct
1type Vault struct {
2	under  *grc20.Token
3	teller grc20.Teller // bound to home, eagerly, at construction
4	home   address
5	tok    *grc20.Token
6	led    *grc20.PrivateLedger
7	pol    Policy
8	held   int64
9}
source

Vault escrows one underlying GRC20 and issues another against it.

The escrow account is the host realm's own address, shared with every other vault that realm creates. Separation between them is the per-vault Held() counter, not separate accounts: a vault only ever releases what it recorded taking in, so the realm-wide invariant is sum(Held) <= Custody().

Methods on Vault

func Allow

method on Vault
1func (v *Vault) Allow(owner, spender address, amount int64) error
source

Allow sets `spender`'s allowance over `owner`'s wrapped balance.

The policy is deliberately not consulted: an allowance moves nothing. It is checked when the allowance is spent, in MoveFrom, so a policy that starts vetoing later still binds allowances granted before.

func Custody

method on Vault
1func (v *Vault) Custody() int64
source

Custody is the underlying actually sitting at the escrow account. It covers every vault the host realm created over the same underlying, plus anything sent there by mistake, so Custody >= Held is necessary for solvency and sufficient only when this is the realm's single vault over that token.

func Donate

method on Vault
1func (v *Vault) Donate(_ int, rlm realm, from address, amount int64) error
source

Donate escrows amount units of the underlying without minting anything.

Under a pool policy this is how the wrapper gets interesting: the donation raises what every outstanding wrapped unit redeems for, so anyone can make every holder richer and nobody can take it back out except by holding. `from` must have approved v.Home() on the underlying, as for Wrap.

func Held

method on Vault
1func (v *Vault) Held() int64
source

Held is the underlying escrowed for THIS vault, by its own accounting.

func Home

method on Vault
1func (v *Vault) Home() address
source

Home is the escrow account: the host realm's address.

func Move

method on Vault
1func (v *Vault) Move(from, to address, amount int64) error
source

Move transfers wrapped units after the policy has had its say. It is the only path a signing user has to move the wrapped token; see the package doc for what that does and does not guarantee.

func MoveFrom

method on Vault
1func (v *Vault) MoveFrom(spender, owner, to address, amount int64) error
source

MoveFrom spends `spender`'s allowance over `owner`'s wrapped balance, after the policy has had its say.

func Policy

method on Vault
1func (v *Vault) Policy() Policy
source

Policy is this vault's personality.

func Solvent

method on Vault
1func (v *Vault) Solvent() bool
source

Solvent reports whether the escrow account still covers what this vault recorded taking in. False means the host realm moved the underlying behind the vault's back.

func Summary

method on Vault
1func (v *Vault) Summary() string
source

Summary renders the vault as a markdown block.

func Supply

method on Vault
1func (v *Vault) Supply() int64
source

Supply is the wrapped token in circulation.

func Token

method on Vault
1func (v *Vault) Token() *grc20.Token
source

Token is the wrapped token this vault issues. It is safe to hand out: a *grc20.Token carries metadata and read access, never the authority to debit anybody.

func Underlying

method on Vault
1func (v *Vault) Underlying() *grc20.Token
source

Underlying is the token held in escrow.

func Unwrap

method on Vault
1func (v *Vault) Unwrap(_ int, rlm realm, from address, amount int64) (int64, error)
source

Unwrap burns amount wrapped units held by `from` and releases the underlying back to it, returning how much was released.

func Wrap

method on Vault
1func (v *Vault) Wrap(_ int, rlm realm, from address, amount int64) (int64, error)
source

Wrap escrows amount units of the underlying from `from` and mints the wrapped token to `from`, returning how much was minted.

`from` must already have granted v.Home() an allowance of at least amount on the underlying token, set through that token realm's own entry point.

Imports 5

Source Files 8