Transaction

6250CDF7AE1E48…E6F2E497E92F

Block 323,350 · index 1 · indexed

Summary

Hash
6250CDF7AE1E486D094D7BF8B674371D607A65C9A9E8C210DFFBE6F2E497E92F
Block
323,350
Size
19348 bytes
Gas used
26,564,234 / 150,000,000
Fee
150000 ugnot
Status
success

Messages

Attached funds
10000000 ugnot

Arguments · 9

  1. #1feeledger
  2. #2feeledger.gno
  3. #3// 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(). package feeledger import ( "errors" "gno.land/p/nt/avl/v0" ) // 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") ) // Ledger tracks per-account balances, their sum (user liabilities), and // separately-accrued protocol fees. The zero value is not usable; // construct with New. 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 } // New returns an empty ledger that rejects deposit fees above maxFeeBps. // maxFeeBps must be in [0, BpsDenominator]. func New(maxFeeBps int64) (*Ledger, error) { if maxFeeBps < 0 || maxFeeBps > BpsDenominator { return nil, ErrInvalidBps } return &Ledger{ maxFeeBps: maxFeeBps, balances: avl.NewTree(), }, nil } // MustNew is New but panics on error. func MustNew(maxFeeBps int64) *Ledger { l, err := New(maxFeeBps) if err != nil { panic(err) } return l } // MaxFeeBps returns the ledger's hard fee cap. func (l *Ledger) MaxFeeBps() int64 { return l.maxFeeBps } // 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]. func FeeFor(amount, bps int64) (int64, error) { if amount < 0 { return 0, ErrInvalidAmount } if bps < 0 || bps > BpsDenominator { return 0, ErrInvalidBps } return feeFor(amount, bps), nil } // feeFor computes floor(amount*bps/BpsDenominator) without intermediate // overflow: amount = q*BpsDenominator + r, so the fee is // q*bps + floor(r*bps/BpsDenominator). q*bps <= amount because // bps <= BpsDenominator, and r*bps < BpsDenominator^2 (10^8), so every // intermediate fits comfortably in int64. Inputs must be pre-validated. func feeFor(amount, bps int64) int64 { q := amount / BpsDenominator r := amount % BpsDenominator return q*bps + r*bps/BpsDenominator } // 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. func (l *Ledger) Deposit(account string, amount, feeBps int64) (credited, fee int64, err error) { if account == "" { return 0, 0, ErrEmptyAccount } if amount <= 0 { return 0, 0, ErrInvalidAmount } if feeBps < 0 || feeBps > l.maxFeeBps { return 0, 0, ErrInvalidBps } fee = feeFor(amount, feeBps) credited = amount - fee // >= 0; > 0 whenever feeBps < BpsDenominator newBalance, ok := checkedAdd(l.BalanceOf(account), credited) if !ok { return 0, 0, ErrOverflow } newUsersTotal, ok := checkedAdd(l.usersTotal, credited) if !ok { return 0, 0, ErrOverflow } newFeesAccrued, ok := checkedAdd(l.feesAccrued, fee) if !ok { return 0, 0, ErrOverflow } // Total liabilities must stay representable as one int64, since they // mirror a single coin balance held by the importing realm. if _, ok := checkedAdd(newUsersTotal, newFeesAccrued); !ok { return 0, 0, ErrOverflow } if newBalance > 0 { l.balances.Set(account, newBalance) } l.usersTotal = newUsersTotal l.feesAccrued = newFeesAccrued return credited, fee, nil } // MustDeposit is Deposit but panics on error. func (l *Ledger) MustDeposit(account string, amount, feeBps int64) (credited, fee int64) { credited, fee, err := l.Deposit(account, amount, feeBps) if err != nil { panic(err) } return credited, fee } // 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. func (l *Ledger) Withdraw(account string, amount int64) error { if account == "" { return ErrEmptyAccount } if amount <= 0 { return ErrInvalidAmount } balance := l.BalanceOf(account) if balance < amount { return ErrInsufficient } if balance == amount { l.balances.Remove(account) } else { l.balances.Set(account, balance-amount) } // usersTotal >= balance >= amount by the sum invariant, so this // cannot underflow. l.usersTotal -= amount return nil } // MustWithdraw is Withdraw but panics on error. func (l *Ledger) MustWithdraw(account string, amount int64) { if err := l.Withdraw(account, amount); err != nil { panic(err) } } // WithdrawAll drains account's entire balance and returns it. Returns // (0, nil) if the account holds nothing. Fails only with // ErrEmptyAccount. func (l *Ledger) WithdrawAll(account string) (int64, error) { if account == "" { return 0, ErrEmptyAccount } balance := l.BalanceOf(account) if balance == 0 { return 0, nil } l.balances.Remove(account) l.usersTotal -= balance return balance, nil } // WithdrawFees drains the accrued fee pot and returns the amount // (0 if nothing has accrued). Never fails. func (l *Ledger) WithdrawFees() int64 { fees := l.feesAccrued l.feesAccrued = 0 return fees } // BalanceOf returns account's balance, or 0 if absent. func (l *Ledger) BalanceOf(account string) int64 { v := l.balances.Get(account) if v == nil { return 0 } return v.(int64) } // UsersTotal returns the sum of all account balances (user liabilities). func (l *Ledger) UsersTotal() int64 { return l.usersTotal } // FeesAccrued returns the fee pot: charged but not yet withdrawn. func (l *Ledger) FeesAccrued() int64 { return l.feesAccrued } // Liabilities returns UsersTotal() + FeesAccrued() — everything the // importing realm owes. Deposit guarantees this sum fits in int64. func (l *Ledger) Liabilities() int64 { return l.usersTotal + l.feesAccrued } // Accounts returns the number of accounts with a non-zero balance. func (l *Ledger) Accounts() int { return l.balances.Size() } // Iterate calls fn for each account in sorted order with its balance. // Iteration stops early when fn returns true. func (l *Ledger) Iterate(fn func(account string, balance int64) bool) { l.balances.Iterate("", "", func(key string, value any) bool { return fn(key, value.(int64)) }) } // checkedAdd returns a+b and reports whether the addition did not // overflow int64. func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
  4. #4feeledger_test.gno
  5. #5package feeledger import "testing" const ( maxInt64 = int64(1<<63 - 1) minInt64 = int64(-1 << 63) ) // mustPanicWith asserts fn panics with an error whose message is want. // Non-crossing panics in the same package are recoverable. func mustPanicWith(t *testing.T, want string, fn func()) { t.Helper() defer func() { r := recover() if r == nil { t.Fatalf("expected panic %q, got none", want) } err, ok := r.(error) if !ok || err.Error() != want { t.Fatalf("panic = %v, want %q", r, want) } }() fn() } func TestNewBounds(t *testing.T) { for _, bad := range []int64{-1, BpsDenominator + 1, minInt64, maxInt64} { if _, err := New(bad); err != ErrInvalidBps { t.Fatalf("New(%d) err = %v, want ErrInvalidBps", bad, err) } } for _, ok := range []int64{0, 1, BpsDenominator} { if _, err := New(ok); err != nil { t.Fatalf("New(%d) err = %v", ok, err) } } mustPanicWith(t, ErrInvalidBps.Error(), func() { MustNew(-1) }) } func TestFeeForRounding(t *testing.T) { // floor(max * 0.9999) computed the same overflow-free way feeFor does. wantMaxAt9999 := maxInt64/10000*9999 + maxInt64%10000*9999/10000 cases := []struct { amount, bps, want int64 }{ {999, 100, 9}, // 9.99 floors to 9 {10000, 1, 1}, // exact {9999, 1, 0}, // 0.9999 floors to 0 — no minimum fee {1, 9999, 0}, // 0.9999 floors to 0 {10, 9999, 9}, // 9.999 floors to 9 {100, 10000, 100}, // full-fee bps: fee == amount {0, 500, 0}, // zero amount previews zero fee {1000, 0, 0}, // zero-fee config {1000, 250, 25}, // exact 2.5% {999, 250, 24}, // 24.975 floors to 24 {maxInt64, 10000, maxInt64}, // no intermediate overflow {maxInt64, 9999, wantMaxAt9999}, } for _, c := range cases { got, err := FeeFor(c.amount, c.bps) if err != nil { t.Fatalf("FeeFor(%d,%d) err = %v", c.amount, c.bps, err) } if got != c.want { t.Fatalf("FeeFor(%d,%d) = %d, want %d", c.amount, c.bps, got, c.want) } if got < 0 || got > c.amount { t.Fatalf("FeeFor(%d,%d) = %d out of [0,amount]", c.amount, c.bps, got) } } if _, err := FeeFor(-1, 100); err != ErrInvalidAmount { t.Fatalf("FeeFor(-1,100) err = %v, want ErrInvalidAmount", err) } if _, err := FeeFor(100, -1); err != ErrInvalidBps { t.Fatalf("FeeFor(100,-1) err = %v, want ErrInvalidBps", err) } if _, err := FeeFor(100, BpsDenominator+1); err != ErrInvalidBps { t.Fatalf("FeeFor over-bps err = %v, want ErrInvalidBps", err) } } func TestDepositValidation(t *testing.T) { l := MustNew(500) type bad struct { account string amount, bps int64 want error } for _, c := range []bad{ {"", 100, 0, ErrEmptyAccount}, {"alice", 0, 0, ErrInvalidAmount}, {"alice", -7, 0, ErrInvalidAmount}, {"alice", minInt64, 0, ErrInvalidAmount}, {"alice", 100, -1, ErrInvalidBps}, {"alice", 100, 501, ErrInvalidBps}, // above the ledger cap } { if _, _, err := l.Deposit(c.account, c.amount, c.bps); err != c.want { t.Fatalf("Deposit(%q,%d,%d) err = %v, want %v", c.account, c.amount, c.bps, err, c.want) } } if l.UsersTotal() != 0 || l.FeesAccrued() != 0 || l.Accounts() != 0 { t.Fatal("failed deposits mutated state") } } func TestDepositAndAccumulate(t *testing.T) { l := MustNew(1000) credited, fee, err := l.Deposit("alice", 1000, 250) if err != nil || credited != 975 || fee != 25 { t.Fatalf("Deposit = (%d,%d,%v), want (975,25,nil)", credited, fee, err) } credited, fee, err = l.Deposit("alice", 999, 250) if err != nil || credited != 975 || fee != 24 { // fee floors, credit rounds up t.Fatalf("Deposit#2 = (%d,%d,%v), want (975,24,nil)", credited, fee, err) } if got := l.BalanceOf("alice"); got != 1950 { t.Fatalf("BalanceOf = %d, want 1950", got) } if l.UsersTotal() != 1950 || l.FeesAccrued() != 49 || l.Liabilities() != 1999 { t.Fatalf("totals = %d/%d/%d", l.UsersTotal(), l.FeesAccrued(), l.Liabilities()) } // credited + fee always reassembles both deposits exactly. if l.Liabilities() != 1000+999 { t.Fatal("conservation broken: credited+fee != deposited") } } func TestFullFeeBpsCreditsZero(t *testing.T) { l := MustNew(BpsDenominator) credited, fee, err := l.Deposit("alice", 777, BpsDenominator) if err != nil || credited != 0 || fee != 777 { t.Fatalf("Deposit = (%d,%d,%v), want (0,777,nil)", credited, fee, err) } if l.Accounts() != 0 || l.BalanceOf("alice") != 0 { t.Fatal("zero-credit deposit must not create an account entry") } if l.FeesAccrued() != 777 || l.UsersTotal() != 0 { t.Fatal("fee pot mismatch on full-fee deposit") } } func TestDepositOverflow(t *testing.T) { l := MustNew(BpsDenominator) if _, _, err := l.Deposit("alice", maxInt64, 0); err != nil { t.Fatalf("seed deposit err = %v", err) } // Account balance would overflow. if _, _, err := l.Deposit("alice", 1, 0); err != ErrOverflow { t.Fatalf("balance-overflow err = %v, want ErrOverflow", err) } // Joint liabilities (users + fees) would overflow even though the // fee pot alone would not. if _, _, err := l.Deposit("bob", 100, BpsDenominator); err != ErrOverflow { t.Fatalf("liabilities-overflow err = %v, want ErrOverflow", err) } if l.BalanceOf("bob") != 0 || l.FeesAccrued() != 0 || l.UsersTotal() != maxInt64 { t.Fatal("failed overflow deposits mutated state") } } func TestWithdraw(t *testing.T) { l := MustNew(0) l.MustDeposit("alice", 1000, 0) if err := l.Withdraw("", 10); err != ErrEmptyAccount { t.Fatalf("empty account err = %v", err) } if err := l.Withdraw("alice", 0); err != ErrInvalidAmount { t.Fatalf("zero amount err = %v", err) } if err := l.Withdraw("alice", -3); err != ErrInvalidAmount { t.Fatalf("negative amount err = %v", err) } if err := l.Withdraw("alice", 1001); err != ErrInsufficient { t.Fatalf("over-balance err = %v", err) } if err := l.Withdraw("ghost", 1); err != ErrInsufficient { t.Fatalf("absent account err = %v", err) } if l.BalanceOf("alice") != 1000 || l.UsersTotal() != 1000 { t.Fatal("failed withdrawals mutated state") } if err := l.Withdraw("alice", 400); err != nil { t.Fatalf("partial withdraw err = %v", err) } if l.BalanceOf("alice") != 600 || l.UsersTotal() != 600 || l.Accounts() != 1 { t.Fatal("partial withdraw wrong state") } if err := l.Withdraw("alice", 600); err != nil { t.Fatalf("exact withdraw err = %v", err) } if l.BalanceOf("alice") != 0 || l.UsersTotal() != 0 || l.Accounts() != 0 { t.Fatal("exact withdraw must remove the account entry") } if err := l.Withdraw("alice", 1); err != ErrInsufficient { t.Fatalf("repeat withdraw err = %v, want ErrInsufficient", err) } mustPanicWith(t, ErrInsufficient.Error(), func() { l.MustWithdraw("alice", 1) }) } func TestWithdrawAll(t *testing.T) { l := MustNew(0) if got, err := l.WithdrawAll("nobody"); err != nil || got != 0 { t.Fatalf("WithdrawAll(nobody) = (%d,%v), want (0,nil)", got, err) } if _, err := l.WithdrawAll(""); err != ErrEmptyAccount { t.Fatalf("WithdrawAll(\"\") err = %v", err) } l.MustDeposit("alice", 555, 0) if got, err := l.WithdrawAll("alice"); err != nil || got != 555 { t.Fatalf("WithdrawAll = (%d,%v), want (555,nil)", got, err) } if got, err := l.WithdrawAll("alice"); err != nil || got != 0 { t.Fatalf("repeat WithdrawAll = (%d,%v), want (0,nil)", got, err) } if l.UsersTotal() != 0 || l.Accounts() != 0 { t.Fatal("WithdrawAll left state behind") } } func TestWithdrawFees(t *testing.T) { l := MustNew(1000) l.MustDeposit("alice", 10000, 1000) // fee 1000 l.MustDeposit("bob", 10000, 1000) // fee 1000 if got := l.WithdrawFees(); got != 2000 { t.Fatalf("WithdrawFees = %d, want 2000", got) } if got := l.WithdrawFees(); got != 0 { t.Fatalf("repeat WithdrawFees = %d, want 0", got) } if l.FeesAccrued() != 0 || l.UsersTotal() != 18000 { t.Fatal("WithdrawFees corrupted totals") } } func TestConservationAcrossSequence(t *testing.T) { l := MustNew(1000) deposited := int64(0) withdrawn := int64(0) feesOut := int64(0) step := func() { t.Helper() // Internal consistency: usersTotal == sum of balances. sum := int64(0) l.Iterate(func(_ string, b int64) bool { if b <= 0 { t.Fatal("zero/negative balance stored") } sum += b return false }) if sum != l.UsersTotal() { t.Fatalf("usersTotal %d != Σ balances %d", l.UsersTotal(), sum) } // Conservation: everything in == everything owed + everything out. if deposited != l.Liabilities()+withdrawn+feesOut { t.Fatalf("conservation: in=%d owed=%d out=%d+%d", deposited, l.Liabilities(), withdrawn, feesOut) } } deposit := func(who string, amt, bps int64) { l.MustDeposit(who, amt, bps) deposited += amt step() } claim := func(who string, amt int64) { l.MustWithdraw(who, amt) withdrawn += amt step() } deposit("alice", 1000, 0) deposit("bob", 999, 250) deposit("alice", 12345, 1000) claim("alice", 1000) deposit("carol", 1, 1000) // fee floors to 0 feesOut += l.WithdrawFees() step() claim("bob", 974) all, _ := l.WithdrawAll("alice") withdrawn += all step() deposit("dave", 7, 999) feesOut += l.WithdrawFees() step() } func TestCheckedAddBranches(t *testing.T) { cases := []struct { a, b, want int64 ok bool }{ {5, 3, 8, true}, {5, -3, 2, true}, // negative-b branch, no overflow {-4, -6, -10, true}, {maxInt64, 1, 0, false}, // positive overflow {minInt64, -1, 0, false}, // negative overflow {maxInt64, -maxInt64, 0, true}, } for _, c := range cases { got, ok := checkedAdd(c.a, c.b) if ok != c.ok || (ok && got != c.want) { t.Fatalf("checkedAdd(%d,%d) = (%d,%v), want (%d,%v)", c.a, c.b, got, ok, c.want, c.ok) } } } func TestIterateSortedAndEarlyStop(t *testing.T) { l := MustNew(0) l.MustDeposit("carol", 3, 0) l.MustDeposit("alice", 1, 0) l.MustDeposit("bob", 2, 0) got := "" l.Iterate(func(a string, _ int64) bool { got += a + "," return false }) if got != "alice,bob,carol," { t.Fatalf("Iterate order = %q", got) } n := 0 l.Iterate(func(string, int64) bool { n++; return n == 2 }) if n != 2 { t.Fatalf("early stop visited %d", n) } }
  6. #6gnomod.toml
  7. #7module = "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger" gno = "0.9"
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

msg:0,success:true,log:,events:[]