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) 910// The holder approves YOUR realm's address on the underlying first, through11// the underlying realm's own entry point. Then:12shares,err:=v.Wrap(0,cur,holder,1000)// escrow 1000, mint shares13out,err:=v.Unwrap(0,cur,holder,shares)// burn shares, release escrow14err=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 PURPLE6err=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".
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 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".
1var( 2ErrNilToken=errors.New("grc20wrap: nil underlying token") 3ErrInvalidAmount=errors.New("grc20wrap: amount must be positive") 4ErrDust=errors.New("grc20wrap: amount rounds to zero") 5ErrUnbacked=errors.New("grc20wrap: payout exceeds this vault's escrow") 6ErrSoulbound=errors.New("grc20wrap: soulbound, the wrapped token cannot change hands") 7ErrOverflow=errors.New("grc20wrap: arithmetic overflow") 8ErrBadRatio=errors.New("grc20wrap: ratio terms must be positive") 9ErrBadBPS=errors.New("grc20wrap: basis points must be within 0..10000")10ErrBadLegs=errors.New("grc20wrap: a basket needs at least two legs, each with a positive part")11ErrShortBalance=errors.New("grc20wrap: insufficient balance on a leg")12ErrShortAllowance=errors.New("grc20wrap: insufficient allowance on a leg")13)
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.
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.
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.
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.
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.
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.
1typePolicyinterface{ 2// WrapRate returns how many wrapped units `in` underlying units mint. 3// It is called BEFORE the escrow moves, so v.Held() excludes `in`. 4WrapRate(v*Vault,inint64)(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`. 8UnwrapRate(v*Vault,inint64)(int64,error) 910// CanMove vetoes a transfer of the wrapped token between two accounts.11CanMove(v*Vault,from,toaddress,amountint64)error1213// Name labels the policy in a catalogue. Short, lowercase.14Name()string15}
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.
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.
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.
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.
1typeVaultstruct{2under*grc20.Token3tellergrc20.Teller// bound to home, eagerly, at construction4homeaddress5tok*grc20.Token6led*grc20.PrivateLedger7polPolicy8heldint649}
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().
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.
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.
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.
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.
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.
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.