Cosmic Bull

invoices

gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices on onyx-1 testnet

realmapplicationreceivableslive on onyx-1

Issue an invoice and let the payer settle it, in part or in full.

Both sides read the same record of what is owed and what has been paid, so there is nothing to reconcile afterwards.

In detail curated

On-chain accounts receivable with REAL ugnot custody: an issuer records an obligation that a NAMED payer owes them, the payer settles it in coins, and the realm holds the settled value until the issuer pulls it. The design axis that separates it from every other custodial realm in this portfolio is that ISSUING MOVES NO COINS AND RESERVES NOTHING -- Issue writes a record and nothing else, so an invoice is a CLAIM and never a deposit, and an issuer can therefore invoice an address that has no intention of paying without the realm's books being affected at all. Issue(payer, amount, dueHeight, memo) refuses issuer == payer, a non-canonical (uppercase) payer address, an amount below MinInvoiceAmount = 1_000, a memo over MaxMemoLen = 256 and a BACKDATED dueHeight -- the last because an invoice overdue in the transaction that issued it is a claim about a deadline that was never offered. Pay is the NAMED PAYER ONLY, direct-EOA-with-send only (coinio's receipt-guaranteed envelope), accepts a partial payment at or above MinPayment = 1_000 OR any amount that settles the invoice exactly, and REFUSES AN OVERPAYMENT rather than keeping it -- accepting one would turn a payment into a donation the payer did not choose and this realm has no path to return. SettlementFeeBps = 100 (1%) is charged per payment on the gross, floor-rounded, into the feeledger fee pot; the remainder credits the ISSUER's ledger balance, which Claim then pulls. Void is ISSUER-ONLY (an issuer forgiving a debt, and nobody else may forgive it for them), does NOT reverse payments already credited, and KEEPS the paid figure so the record stays readable. Release is PERMISSIONLESS and refuses an open invoice -- which is exactly what makes it safe to let a stranger call, because a Release can never destroy an outstanding obligation -- and the chain's storage refund to the caller is the standing incentive to tidy a record that is not your own. Releasing is the ONLY way an issuer's slot against MaxRecordsPerIssuer = 64 is ever freed: settling and voiding do not free one, because the record is still stored and still walked by every issuer-scoped getter. Keys are issuer|padID(id) zero-padded to 19 digits, so each issuer's records are a contiguous avl prefix range and one issuer's iteration can never be charged for another's. The monetary invariant is Held() == Liabilities() + Surplus() WITH NO POOL TERM -- unlike r/payroll and r/budgets, nothing is ever pre-funded here, so every coin the realm holds is either a credited settlement, an accrued fee, or an unaccounted surplus that only SweepSurplus can remove. Guards: the PINNED rejectStraySend variant (!cur.IsCurrent() first) on every mutating entrypoint, checkedAdd on the issuer lifetime total, MaxFeeBps = 500, and mustDecOpen PANICS on an impossible open-count rather than masking it. FeeCollector is ADDRESS-AS-DATA -- a hard-coded g1ut6... constant that any mainnet port must classify before namespace_tree.py's blind str.replace rewrites the fee destination, documented in-source for exactly that reason.

Use it

A realm holds state. Reading it is a free query; changing it is a transaction you sign yourself.

Read it — no key, no account, no GNOT

gnokey query vm/qeval \
  --data 'gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices.FeesAccrued()' \
  --remote https://rpc.onyx.testnets.gno.land:443

FeesAccrued is read-only, so this is a query rather than a transaction: it costs nothing, signs nothing and needs no funded account. Every exported function is listed under the API below; swap the name and the arguments to read any of the others.

Change its state

Every state change is a transaction you sign with your own key — this site never asks for one and holds none. You do not have to hand-write the transaction: gnoweb generates the exact gnokey maketx call command for any of this realm's 7 crossing functions, with the argument names filled in.

Read the known limitations further down this page first. They record what was not verified on chain, which is usually the part that matters.

Import it from another realm

import "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices"

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 latefees, a deployed package in this portfolio that imports this one as invoices, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.

if !invoices.HasInvoice(issuer, invoiceID) {
onyx/r/latefees/latefees.gno line 146, onyx-1 testnet
if !invoices.IsOverdue(issuer, invoiceID) {
onyx/r/latefees/latefees.gno line 153, onyx-1 testnet
payer, amount, paid, dueHeight, _, _ := invoices.InvoiceInfo(issuer, invoiceID)
onyx/r/latefees/latefees.gno line 161, onyx-1 testnet

Deployments

onyx-1 testnet onyx-1

RenderedSourceCall builderState

chain-attested
Import pathgno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices
Namespaceg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Deployed at height378,326
Deploy transaction37a702620558b03754fbc153722852d1ff01fa3c62df859e55f37e5ff0f02a3e look it up on the RPC
Deployerg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Gas used49,616,630
Storagenot attested per package on this chain — see the limitations below
Files on chaininvoices.gno gnomod.toml
Deployed bytesinvoices.gno — 36,868 bytes
sha256bbc4d71846197bdfa2bd8813fe406f07759d1ebd5f1ee9f5e7cefd5af5a1edce
Realm addressg1t5nnvlet40cs353n3gth9fvxkqm2qjazptns4z confirmed against the realm itself

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/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices$download&file=invoices.gno' | shasum -a 256

Expected: bbc4d71846197bdfa2bd8813fe406f07759d1ebd5f1ee9f5e7cefd5af5a1edce — 36,868 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.

API

chain-derived 29 exported functions.

Every function below deep-links to gnoweb's call builder, which generates a ready-to-run gnokey maketx call for it:

BalanceOfClaimFeesAccruedHasInvoiceHasOverdueFromHeightHeldIsOverdueIssueIssuedOfIssuersLiabilitiesMemoNextIDOpenOfOutstandingOutstandingFromOverdueFromPayRecordsRecordsOfReleaseRenderSettledTotalStatusOfSurplusSweepSurplusVoidWithdrawFees

Overview

Package invoices is on-chain accounts receivable: an issuer records an obligation, and the named payer discharges it later, in parts.

The one idea

Every payment realm in this portfolio, and every escrow and vesting package the discovery gate found on either live chain, is PREFUNDED. service_market escrows the exact price at purchase. subscriptions prepays a whole period. payroll, vesting, fee_split and revshare push money that already arrived. In all of them the coins exist before the claim on them does.

An invoice inverts that. It is UNFUNDED AT CREATION: the obligation is recorded first, and the money may never arrive at all.

That one inversion is the whole realm, and it is visible in the arithmetic rather than only in the prose. payroll's invariant carries a pool term because an employer funds first:

payroll: Held() == TotalPooled() + Liabilities() + Surplus() invoices: Held() == Liabilities() + Surplus()

There is no pool here, and no reserve, because at the moment an invoice is issued this realm holds nothing on its behalf and promises nothing. Issuing costs the issuer a storage deposit and nothing else.

What being unfunded forces

Two features that would be decoration on a prefunded rail are load- bearing here, and they are the reason this is a realm rather than a field on an existing one:

No escrow, no acceptance, no arbitration

An invoice is billed for work already done, so there is nothing for this realm to hold back. Money paid is credited to the issuer's ledger balance in the same transaction. There is no acceptance window and no release step — those belong to service_market, whose model is the opposite one.

Consequently this realm DOES NOT arbitrate. A payer who disputes a bill simply does not pay it, and the invoice goes overdue. That is the mechanism off chain too, and inventing a dispute path here would make this a worse copy of service_market rather than a complement to it.

Payment is pull, in the direction that cannot fail

Pay credits the issuer's ledger balance; the issuer later calls Claim. The push alternative — paying the issuer inside the payer's own transaction — would let the issuer's address decide whether the PAYER's settlement succeeds. An issuer that is a realm which panics on receipt, or is simply unable to accept, would make its own invoices unpayable and the failure would land on the payer. Pull puts every failure on the party who owns it.

Anyone may issue an invoice naming anyone. Read this before trusting a total.

There is no permission to bill someone, because requiring one would mean a registry of who may invoice whom, which is a different application. The honest consequence is stated here rather than discovered later:

AN UNPAID INVOICE IS AN UNVERIFIED CLAIM BY ITS ISSUER.

So this realm deliberately exposes NO global "what does this address owe" aggregate. Such a number would be the sum of every stranger's self-reported assertion, and reading it as a credit signal is the error the getter's existence would invite. What it exposes instead is ISSUER-SCOPED: OutstandingFrom and OverdueFrom answer "what does this payer owe THIS issuer", which is the question a consumer realm can actually act on, because a consumer gating its own service on its own unpaid bills is trusting only itself.

Two bounds back that up mechanically. Issuing costs a storage deposit paid by the ISSUER, so volume is not free to the party who benefits from it; and MaxRecordsPerIssuer caps one issuer's STORED records, which is an availability bound on that issuer's own namespace and no one else's. Neither bound makes an unpaid claim true, and they are not presented as doing so.

Both bounds are PER ISSUER, and the global total is not bounded. Sybil issuers multiply it: N funded addresses yield N*MaxRecordsPerIssuer records, and the per-record deposit paid by each is the only thing making that expensive. Two consequences follow, stated here rather than discovered:

The cap counts STORED records rather than OPEN ones, and the difference is load-bearing rather than a detail. OutstandingFrom, OverdueFrom and HasOverdueFrom — the getters a consumer realm calls, and therefore the ones whose gas cost is someone else's problem — iterate an issuer's whole prefix, settled and voided records included, because a settled record is not removed until Release. A cap on open invoices alone would bound the obligations and leave that iteration unbounded: an issuer could issue and void repeatedly, keeping `open` at zero while their prefix grew without limit, until a consumer's read ran out of gas. Capping what is stored bounds both, since open can never exceed stored.

Finding "every invoice naming me" is therefore an off-chain indexer's job: on chain it would be an unbounded iteration over every issuer that has ever billed anyone, and the answer would be untrustworthy anyway.

STORAGE

An issuer record is paid for by that issuer's first Issue; an invoice record by the Issue that creates it. Release removes a finished invoice and the chain refunds the freed deposit to the transaction that frees it, which is the standing incentive to tidy up a record that is no longer anyone's business.

What this realm does NOT do

Imports

Constants and variables

Invoice statuses.

const (
	StatusOpen    = "open"
	StatusSettled = "settled"
	StatusVoided  = "voided"
)

Denom is the only asset this realm holds.

const Denom = "ugnot"

FeeCollector receives withdrawn fees and swept surplus. Compile-time constant: there is no setter and no transfer path. It is the deploying namespace's address — stated plainly: the operator of this realm.

NOTE FOR ANY RE-NAMESPACED PORT: this is the address-as-data hazard that a namespace substitution rewrites silently while every byte-comparison still reports the tree faithful, because the rewrite IS the intended transformation. Re-state it deliberately per chain; never let it be derived.

const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")

MaxFeeBps is the hard ceiling on any fee this realm could ever charge, enforced by the feeledger the realm is constructed with — a feeBps above it is refused by the primitive's own validation, not by a check in this file. 500 bps = 5%.

const MaxFeeBps = int64(500)

MaxMemoLen bounds the free-text memo, in bytes. The memo is rendered, so it is sanitized at the render boundary, never at the write boundary — storing what the issuer actually wrote keeps the record honest.

const MaxMemoLen = 256

MaxRecordsPerIssuer caps the invoice records one issuer may have STORED at once — open, settled and voided alike. It is what makes the issuer-scoped getters gas-bounded; see the package doc for why the cap counts stored records and not open ones.

Issue is permissionless, so this is an AVAILABILITY BOUND and it is documented rather than hidden: an issuer can exhaust their OWN capacity and no one else's, because the bound is per-issuer. The cost of filling it is gas plus a storage deposit on every record.

Hitting it is self-service to clear: Release is permissionless and the chain refunds the freed deposit to whoever calls it, so an issuer at the cap can always release their own finished records, and a stranger has a standing incentive to do it for them.

const MaxRecordsPerIssuer = 64

MinInvoiceAmount is the smallest invoice that may be issued. Below the fee denominator a 1% fee floors to zero, so a smaller invoice would be fee-free by accident rather than by design.

const MinInvoiceAmount = int64(1_000)

MinPayment keeps dust settlements out. It is NOT a floor on the final payment: a payment is accepted when it is at least MinPayment OR when it settles the invoice exactly. Without that second clause an invoice with 300 ugnot outstanding could never be closed, and "unpayable remainder" is a worse failure than dust.

const MinPayment = int64(1_000)

RealmPath is this realm's own path, used to build Render links.

const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices"

SettlementFeeBps is charged on each payment, on the gross, floor-rounded in the ISSUER's favour: 100 bps = 1%. It matches the most conservative live fee in this portfolio (fee_split, crowdfund, budgets, payroll).

The ISSUER bears it, not the payer: a payment of N reduces the outstanding balance by exactly N, and the issuer is credited N minus the fee. A payer's debt must be discharged by what the payer sent, or the arithmetic of "paid in full" would depend on this realm's fee schedule.

It is a compile-time constant with no setter, so an invoice's economics cannot be changed after it is issued.

The fee is floored PER PAYMENT, so splitting a payment can lower the total fee. Each payment boundary forfeits at most 1 ugnot, and MinPayment bounds how many boundaries an invoice admits. MEASURED on onyx-1, not reasoned: 10,990 paid at once charges 109, while ten payments of 1,099 charge 100. The beneficiary is the ISSUER, who is credited the difference and does not control the payment schedule; the payer gains nothing, because the outstanding balance falls by the gross either way. Gas for one extra payment costs more than the 1 ugnot it saves, so this is priced out rather than prevented, and a colluding issuer/payer pair would spend more to exploit it than they recovered.

A final settlement below the fee denominator pays zero fee for that same reason and is NOT a separate case: floor(99/100) is 0 whether or not MinPayment's settle-exactly clause admitted it, so that clause forfeits nothing on its own.

const SettlementFeeBps = int64(100)

Functions

BalanceOf

func BalanceOf(issuer address) int64

BalanceOf is an issuer's settled, unclaimed balance.

Claim

func Claim(cur realm) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as Claim(cross(cur), ...).

Claim pays the caller their whole settled balance. The issuer only — there is no "claim on behalf of", because that would let a third party choose the moment someone else's coins move.

FeesAccrued

func FeesAccrued() int64

FeesAccrued is the fee pot awaiting WithdrawFees.

HasInvoice

func HasInvoice(issuer address, id int64) bool

HasInvoice reports whether a record exists, without panicking on absence — every other invoice accessor panics, which makes them unusable as an existence probe.

HasOverdueFrom

func HasOverdueFrom(issuer, payer address) bool

HasOverdueFrom is OverdueFrom as a predicate, for a consumer that only needs the boolean. It stops at the first hit rather than summing.

Height

func Height() int64

Height is the current chain height, exposed so a caller can compute due heights against the same clock this realm uses.

Held

func Held() int64

Held is the realm's actual ugnot balance, read from the chain.

IsOverdue

func IsOverdue(issuer address, id int64) bool

IsOverdue reports whether one invoice is overdue right now.

Issue

func Issue(cur realm, payer address, amount, dueHeight int64, memo string) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as Issue(cross(cur), ...).

Issue records an obligation of `amount` owed by `payer` to the caller, to be settled by `dueHeight`. It moves no coins and reserves nothing. Returns the invoice id.

dueHeight must not be in the past. A backdated due height would create an invoice that is overdue in the transaction that issued it, which is a claim about a deadline that was never offered.

IssuedOf

func IssuedOf(issuer address) (count, invoiced int64)

IssuedOf is how many invoices an address has ever issued, and their total face value. Note the tense: both are lifetime figures and neither falls when an invoice is settled, voided or released.

Issuers

func Issuers() int

Issuers is the number of addresses that have ever issued an invoice.

Liabilities

func Liabilities() int64

Liabilities is every coin this realm owes to anyone: the ledger's own total, which is issuer balances plus the fee pot.

There is no pool term, and its ABSENCE is the unfunded property this realm exists to model — see the package doc. Do not "fix" this to match payroll's shape.

Memo

func Memo(issuer address, id int64) string

Memo is an invoice's free-text memo, exactly as the issuer wrote it. It is UNSANITIZED and caller-supplied: a consumer that renders it owes it the same treatment Render gives it here.

NextID

func NextID() int64

NextID is the id the next Issue will allocate. Exposed so a caller can tell a never-issued id from a released one.

OpenOf

func OpenOf(issuer address) int

OpenOf is the number of open invoices an issuer holds.

Outstanding

func Outstanding(issuer address, id int64) int64

Outstanding is what is still owed on one invoice. Zero for a settled or voided one — a voided invoice owes nothing by definition, which is the point of voiding it.

OutstandingFrom

func OutstandingFrom(issuer, payer address) int64

OutstandingFrom is the total a payer still owes THIS issuer across the issuer's open invoices.

The iteration is bounded by MaxRecordsPerIssuer, NOT by the number of open invoices: it walks the issuer's whole stored prefix and skips the records that are not open. That is why the cap counts stored records — see the package doc.

This is the issuer-scoped shape the package doc argues for: a consumer realm gating its own service on its own unpaid bills is trusting only itself. There is deliberately no global per-payer total — see the package doc, "Anyone may issue an invoice naming anyone".

OverdueFrom

func OverdueFrom(issuer, payer address) int64

OverdueFrom is OutstandingFrom restricted to invoices whose due height has passed. A consumer realm that wants to withhold service from a late payer reads this.

Pay

func Pay(cur realm, issuer address, id int64) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as Pay(cross(cur), ...).

Pay settles part or all of an invoice with the attached coins. The named payer only. Direct EOA calls with -send only (the receipt-guaranteed shape); exactly one positive ugnot coin.

The attached amount must not exceed the outstanding balance. Accepting an overpayment would turn a payment into a donation the payer did not choose to make and this realm has no path to return, so it is refused and the payer keeps their coins.

Returns the outstanding balance remaining after this payment.

Records

func Records() int

Records is the number of invoice records currently stored, open or not. It falls on Release, so it is a storage figure rather than a count of invoices that have existed.

RecordsOf

func RecordsOf(issuer address) int

RecordsOf is the number of invoice records an issuer currently has stored, of any status. This — not OpenOf — is the figure capped by MaxRecordsPerIssuer, so it is the one an issuer near the cap needs.

Release

func Release(cur realm, issuer address, id int64)

Crossing function. Callable from a transaction via MsgCall, and from another realm as Release(cross(cur), ...).

Release removes a finished invoice and frees its storage. Permissionless: the chain refunds the freed storage deposit to the caller, which is the standing incentive to tidy up a record that is not your own.

It refuses an open invoice, which is what makes it safe to let a stranger call: a Release can never destroy an outstanding obligation. An issuer who wants a record gone must Void it first, and that leaves an event.

Releasing frees the issuer's slot against MaxRecordsPerIssuer. That is the only way a slot is ever freed — settling and voiding do not free one, because the record is still stored and still walked by every issuer-scoped getter.

Render

func Render(path string) string

SettledTotal

func SettledTotal() int64

SettledTotal is the sum of every issuer's unclaimed balance.

StatusOf

func StatusOf(issuer address, id int64) string

StatusOf is an invoice's status.

Surplus

func Surplus() int64

Surplus is coins held beyond every obligation — what SweepSurplus would move. It is zero when every coin arrived through an entrypoint.

SweepSurplus

func SweepSurplus(cur realm) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as SweepSurplus(cross(cur), ...).

SweepSurplus sends coins held beyond every obligation to FeeCollector. The reserve passed to coinio is the realm's full liability, so a sweep can never reach a coin an issuer is owed.

Void

func Void(cur realm, id int64)

Crossing function. Callable from a transaction via MsgCall, and from another realm as Void(cross(cur), ...).

Void cancels the unpaid remainder of an invoice. The issuer only — this is an issuer forgiving a debt, and nobody else may forgive it for them.

It does NOT reverse payments already made. Coins the payer sent are already credited to the issuer's ledger balance and may already have been claimed; there is no mechanism on chain to take them back, and pretending otherwise in the status would be worse than saying so. A voided invoice therefore keeps its `paid` figure, which is what makes the record readable afterwards.

Voiding does not remove the record. Release does that.

WithdrawFees

func WithdrawFees(cur realm) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as WithdrawFees(cross(cur), ...).

WithdrawFees moves the accrued fee pot to FeeCollector. Anyone may call it; the destination is a compile-time constant, so the caller cannot influence where the coins go.


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

chain-attested
Importschain, chain/runtime, chain/runtime/unsafe, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger, gno.land/p/nt/avl/v0, gno.land/p/nt/markdown/sanitize/v0, strconv, strings
First-party dependenciesgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger

Known limitations

Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.

curated

Source

Source fileonyx/r/invoices/invoices.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository)
Matches the deployed bytesyes — byte-identical