feeledger
gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger on gno.land mainnetgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger on onyx-1 testnet
pure packageprimitiveaccountinglive on mainnetlive on onyx-1
Keep per-account balances and protocol fees in separate books.
Tracks what each user is owed apart from what the protocol has earned, so the two can never be spent out of the same pot.
In detail curated
Per-account balance ledger with an explicit basis-point fee pot; error-pure and overflow-checked. Conservation contract: held == UsersTotal + FeesAccrued + surplus.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
on gno.land mainnet
import "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"on onyx-1 testnet
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.
Called from real code
Not an example written for this page. These lines are quoted from service_market, a deployed package in this portfolio that imports this one as feeledger, out of the committed file whose sha256 gno.land mainnet attested. They compile because they are compiled.
ledger = feeledger.MustNew(MaxFeeBps)f, err := feeledger.FeeFor(s.price, s.feeBps)Deployments
Deployed on 2 chains from the same source. The bytes are identical — one digest, bb2e1058973d31b0…, on both — so the API below is the API on either. Everything that is not the bytes differs per chain and is stated per chain: the import path, the namespace, the transaction, the height, the cost, and which files the chain holds.
gno.land mainnet gnoland-1
chain-attested| Import path | gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger |
|---|---|
| Namespace | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Deployed at height | 323,350 |
| Deploy transaction | 6250cdf7ae1e486d094d7bf8b674371d607a65c9a9e8c210dffbe6f2e497e92f look it up on the RPC |
| Deployer | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Gas used | 26,564,234 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | feeledger.gno feeledger_test.gno gnomod.toml |
| Deployed bytes | feeledger.gno — 8,723 bytes |
| sha256 | bb2e1058973d31b09a5e6be99c4cd60f2ef155a64eb259191fcfd590dcea0d9f |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/budgets, r/crowdfund, r/revshare, r/service_market, r/subscriptions, r/upkeep, r/vesting |
|---|
Do not take the hash above on trust. $download returns the bytes gnoland-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger$download&file=feeledger.gno' | shasum -a 256Expected: bb2e1058973d31b09a5e6be99c4cd60f2ef155a64eb259191fcfd590dcea0d9f — 8,723 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 96,600 |
| Deploy transaction | f956e9dd0dba54c9409918f29c32e8486fdc0c6a69b6504f412d34f56d88612e look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 26,563,620 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | feeledger.gno feeledger_test.gno gnomod.toml |
| Deployed bytes | feeledger.gno — 8,723 bytes |
| sha256 | bb2e1058973d31b09a5e6be99c4cd60f2ef155a64eb259191fcfd590dcea0d9f |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/auction, r/budgets, r/crowdfund, r/deposits, r/invoices, r/loans, r/meters, r/netting, r/payroll, r/reimbursements, r/sealedbid, r/subscriptions |
|---|
Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger$download&file=feeledger.gno' | shasum -a 256Expected: bb2e1058973d31b09a5e6be99c4cd60f2ef155a64eb259191fcfd590dcea0d9f — 8,723 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 3 exported functions, 1 type, 13 methods.
Read from vm/qdoc on gnoland-1. It describes the onyx-1 testnet deployment as well: the deployed bytes are identical on every chain here — one digest, bb2e1058973d31b0… — and this site refuses to build a shared page for a package whose bytes differ across chains, so the same API is the API on each. The API is the only thing that carries across; every provenance fact above is per-chain.
This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.
Overview
Package feeledger is a pure accounting primitive for realms that hold coins on behalf of users and charge an explicit protocol fee on deposits. It tracks per-account balances, the sum of user liabilities, and a separately-accrued fee pot. It never touches coins itself: the importing realm moves coins and drives this ledger, keeping the two in lock-step so that
coins held by realm == UsersTotal() + FeesAccrued() + surplus
where surplus is coins pushed to the realm outside the ledger's flow (always >= 0, and 0 if every coin movement goes through the ledger).
Fee model:
- Fees are expressed in basis points (1 bps = 0.01%); BpsDenominator is 10000, so bps == 10000 means the whole deposit is fee.
- fee = floor(amount * bps / 10000). Rounding always favors the DEPOSITOR: any fractional fee is dropped, the account is credited amount - fee. A small deposit may therefore pay zero fee.
- There is no minimum fee. The maximum fee is bounded by the per-ledger cap set at construction (maxFeeBps <= 10000), so a fee can never exceed its deposit.
- bps == 0 is a valid configuration: fee is exactly 0 and the full amount is credited.
All failures are returned as errors and leave the ledger COMPLETELY UNCHANGED; callers in realms typically wrap calls so an error panics and aborts the transaction. Must* wrappers are provided and are the only functions in this package that panic.
The ledger is address-agnostic: accounts are non-empty strings. Realms normally use address.String().
Imports
errorsgno.land/p/nt/avl/v0
Constants and variables
BpsDenominator is the fee basis: fees are bps/BpsDenominator of the deposited amount.
const BpsDenominator = int64(10000)
Errors returned by Ledger operations.
var (
ErrEmptyAccount = errors.New("feeledger: empty account key")
ErrInvalidAmount = errors.New("feeledger: amount must be positive")
ErrInvalidBps = errors.New("feeledger: fee bps out of range")
ErrInsufficient = errors.New("feeledger: insufficient balance")
ErrOverflow = errors.New("feeledger: int64 overflow")
)
Types
type Ledger
type Ledger struct {
maxFeeBps int64
balances *avl.Tree // account string -> int64 (always > 0)
usersTotal int64 // == sum of all balances
feesAccrued int64 // fees charged but not yet withdrawn
}
Ledger tracks per-account balances, their sum (user liabilities), and separately-accrued protocol fees. The zero value is not usable; construct with New.
Ledger.Accounts
func (l *Ledger) Accounts() int
Accounts returns the number of accounts with a non-zero balance.
Ledger.BalanceOf
func (l *Ledger) BalanceOf(account string) int64
BalanceOf returns account's balance, or 0 if absent.
Ledger.Deposit
func (l *Ledger) Deposit(account string, amount, feeBps int64) (credited, fee int64, err error)
Deposit credits account with amount minus the fee at feeBps, accruing the fee to the fee pot. Returns the credited amount and the fee (credited + fee == amount always). Fails with ErrEmptyAccount, ErrInvalidAmount (amount <= 0), ErrInvalidBps (feeBps outside [0, MaxFeeBps]), or ErrOverflow if the account balance or total liabilities would leave int64 range. On error nothing is modified.
Ledger.FeesAccrued
func (l *Ledger) FeesAccrued() int64
FeesAccrued returns the fee pot: charged but not yet withdrawn.
Ledger.Iterate
func (l *Ledger) Iterate(fn func(account string, balance int64) bool)
Iterate calls fn for each account in sorted order with its balance. Iteration stops early when fn returns true.
Ledger.Liabilities
func (l *Ledger) Liabilities() int64
Liabilities returns UsersTotal() + FeesAccrued() — everything the importing realm owes. Deposit guarantees this sum fits in int64.
Ledger.MaxFeeBps
func (l *Ledger) MaxFeeBps() int64
MaxFeeBps returns the ledger's hard fee cap.
Ledger.MustDeposit
func (l *Ledger) MustDeposit(account string, amount, feeBps int64) (credited, fee int64)
MustDeposit is Deposit but panics on error.
Ledger.MustWithdraw
func (l *Ledger) MustWithdraw(account string, amount int64)
MustWithdraw is Withdraw but panics on error.
Ledger.UsersTotal
func (l *Ledger) UsersTotal() int64
UsersTotal returns the sum of all account balances (user liabilities).
Ledger.Withdraw
func (l *Ledger) Withdraw(account string, amount int64) error
Withdraw debits amount from account. Fails with ErrEmptyAccount, ErrInvalidAmount (amount <= 0), or ErrInsufficient if the balance is smaller than amount. A balance drained to zero is removed from storage. On error nothing is modified.
Ledger.WithdrawAll
func (l *Ledger) WithdrawAll(account string) (int64, error)
WithdrawAll drains account's entire balance and returns it. Returns (0, nil) if the account holds nothing. Fails only with ErrEmptyAccount.
Ledger.WithdrawFees
func (l *Ledger) WithdrawFees() int64
WithdrawFees drains the accrued fee pot and returns the amount (0 if nothing has accrued). Never fails.
Functions
FeeFor
func FeeFor(amount, bps int64) (int64, error)
FeeFor returns the fee charged on amount at bps, using the ledger's rounding rule: floor(amount * bps / BpsDenominator). It is a pure preview — no state is read or written beyond validation against BpsDenominator (NOT the ledger cap; use it to inspect any policy). amount must be >= 0 and bps in [0, BpsDenominator].
MustNew
func MustNew(maxFeeBps int64) *Ledger
MustNew is New but panics on error.
New
func New(maxFeeBps int64) (*Ledger, error)
New returns an empty ledger that rejects deposit fees above maxFeeBps. maxFeeBps must be in [0, BpsDenominator].
Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.
Dependencies
A first-party dependency is imported from the SAME chain's namespace, so each deployment above depends on that chain's copy — the paths differ even where the code does not.
chain-attested| Imports | errors, gno.land/p/nt/avl/v0 |
|---|---|
| First-party dependencies | none |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curatedgno.land mainnet
- The unexported-*Ledger-pointer consumer rule is documented in the catalog but not in-package; the deployed copy is frozen.
- Consumed on this chain by revshare, service_market, subscriptions as of 2026-09-25 (Group 1). Before that this entry read "no consumer on this chain"; the claim was true when written and the reusability argument it qualified was carried over from pearl-1.
- storage_bytes and storage_deposit are null because gnoland-1 emits no /tm.StorageDepositEvent ON ADDPKG -- a scope that is load-bearing, because it DOES emit one on MsgCall (measured 2026-10-17, AL62); see the chain's storage_deposit_events field. The deposits actually charged were measured from the signer's balance and are recorded in [an unpublished record], Cost; they are not chain-attested per package by this manifest.
- The deployed file set includes the _test.gno file. That is deliberate for a /p/ library and costs gas rather than storage, but it is a real difference from the pearl-1 deployment of the same source, where gnomcp stripped test files -- so the two chains' file sets are not expected to match.
- F1 (same as the onyx entry): unauthenticated account/feeBps parameters, thinnest consumer contract of the trio; all live consumers verified safe; obligation recorded, bytes immutable. Audit 2026-11-04, disclosed per the operator's disclose-and-queue-successor rule; details and severity in [an unpublished record].
onyx-1 testnet
- F1: Deposit/Withdraw/WithdrawAll take an unauthenticated account string and feeBps with the thinnest consumer contract of the data-layer trio -- no 'authorize the caller' warning, unlike journal/duebook. Every live consumer verified to derive the account from cur.Previous().Address(); the obligation is recorded here since the live bytes cannot change. RED in any realm passing caller-chosen account/feeBps. Audit 2026-11-04, disclosed per the operator's disclose-and-queue-successor rule; details and severity in [an unpublished record].
Source
| Source file (gnoland-1) | mainnet/p/feeledger/feeledger.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
|---|---|
| Matches the deployed bytes (gnoland-1) | yes — byte-identical |
| Source file (onyx-1) | onyx/p/feeledger/feeledger.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
| Matches the deployed bytes (onyx-1) | yes — byte-identical |