Transaction
E8AB38860E74E3…15DFC9DC0E4F
Block 351,422 · index 0 · indexed
Summary
- Hash
- E8AB38860E74E35ABF7F0EF2B02BBA5E3B84744B61C53AF113FE15DFC9DC0E4F
- Block
- 351,422
- Size
- 61132 bytes
- Gas used
- 79,913,652 / 250,000,000
- Fee
- 250000 ugnot
- Status
- success
Messages
- Attached funds
- 12000000 ugnot
Arguments · 9
- #1fee_split
- #2fee_split.gno
- #3package fee_split import ( "chain" "chain/banker" "chain/runtime/unsafe" "sort" "strconv" "strings" ) // Split holds a fee-splitting configuration with percentage-based shares // denominated in basis points (1 bp = 0.01%, 10000 bp = 100%). type Split struct { Owner address PendingOwner address // two-step handover, must AcceptOwnership Recipients []address Shares []int64 // basis points, must sum to 10000 Balances map[address]int64 TotalDeposited int64 TotalClaimed int64 Frozen bool Archived bool } const ( MaxRecipients = 20 MaxSplitsPerOwner = 10 // Quotas are PER-OWNER only (round-4 audit): a global cap is a shared // resource 50 sybil accounts could fill forever — and seeding grief // splits with balances to keyless recipients made the fill // unrecoverable even by the sybils. Per-owner quotas mean an attacker // consumes only their own budget; state growth is gas-priced. // Render is bounded separately (MaxRenderSplits). MaxRenderSplits = 100 // MaxFeeBps is an IMMUTABLE ceiling on the protocol fee (1%). The // admin can set any fee from 0 up to this cap, never above it — the // cap, not the current setting, is what users must trust. MaxFeeBps = int64(100) // Pre-parse input bounds (round-4 audit): caps were enforced only // AFTER full parsing, so a 1MB recipient list burned ~11B gas before // refusal. 20 bech32 addresses + separators fit well within these. MaxRecipientListLen = 1024 MaxShareListLen = 128 // Denomination handled by this realm. Deposits must be exactly one // coin of this denom; claims pay out in it. Denom = "ugnot" // Largest single deposit for which share math (amount * share, // share <= 10000) cannot overflow int64. MaxDepositAmount = int64(9223372036854775807) / 10000 ) var ( splits map[string]*Split splitIDs []string // insertion-ordered for deterministic Render ownerSplits map[address]int nextID int // Protocol fee: taken from each deposit BEFORE distribution, at the // rate in force at deposit time (never retroactive — credited // balances are never touched). Defaults to zero. feeBps int64 feeAdmin address // deployer; can set the fee and claim accrued fees pendingFeeAdmin address // two-step handover, must AcceptFeeAdmin feesAccrued int64 feesClaimed int64 ) func init() { splits = make(map[string]*Split) splitIDs = []string{} ownerSplits = make(map[address]int) nextID = 1 // The package deployer becomes the fee admin: on-chain, AddPackage // runs init with the message creator as origin caller (verified // against the VM keeper — a MsgAddPackage's creator is never zero). // If OriginCaller() were ever empty (e.g. the gno test VM, which has // no MsgAddPackage), the fee feature degrades SAFELY to disabled: // SetFee requires the runtime-derived caller to equal feeAdmin and // that caller is never empty, so // feeBps can never leave 0 and no fee ever accrues — zero funds at // risk. Do not "harden" this into a panic; the soft-disable is the // safe behavior. Fee starts at ZERO regardless. feeAdmin = unsafe.OriginCaller() feeBps = 0 } // ---------- helpers ---------- func formatPct(bp int64) string { whole := strconv.FormatInt(bp/100, 10) frac := strconv.FormatInt(bp%100, 10) if len(frac) == 1 { frac = "0" + frac } return whole + "." + frac + "%" } func mustGetSplit(id string) *Split { s, ok := splits[id] if !ok { panic("split not found: " + id) } return s } func mustGetActive(id string) *Split { s := mustGetSplit(id) if s.Archived { panic("split is archived: " + id) } return s } func boolStr(v bool) string { if v { return "yes" } return "no" } func parseBasisPoints(raw string) []int64 { if len(raw) > MaxShareListLen { panic("share list too long") } parts := strings.Split(raw, ",") out := make([]int64, len(parts)) var total int64 for i, s := range parts { v, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || v <= 0 || v > 10000 { // the upper bound is a security invariant, not hygiene: unbounded // shares let the int64 total wrap back to exactly 10000, minting // unbacked balances paid from the shared pool (re-audit P1) panic("invalid share value: " + strings.TrimSpace(s)) } out[i] = int64(v) total += int64(v) } if total != 10000 { panic("shares must sum to 10000 basis points, got " + strconv.FormatInt(total, 10)) } return out } func parseAddresses(raw string) []address { if len(raw) > MaxRecipientListLen { panic("recipient list too long") } parts := strings.Split(raw, ",") out := make([]address, len(parts)) for i, r := range parts { a := address(strings.TrimSpace(r)) if a == "" { panic("empty recipient address at position " + strconv.Itoa(i)) } if !a.IsValid() || string(a) != strings.ToLower(string(a)) { // lowercase is required, not cosmetic (round-3 audit): bech32 // accepts ALL-UPPERCASE as valid, but the runtime always returns // the lowercase canonical form — an uppercase-keyed balance // could never be claimed and would block Archive forever, and // upper/lower duplicates would bypass the duplicate check panic("invalid recipient address: " + string(a)) } // NOTE: IsValid is a format check only — a well-formed address with // no key holder (e.g. another package's derived address) will // accumulate a balance nobody can claim, which also blocks Archive // forever. Owners must list addresses they know can call Claim. out[i] = a } return out } func validateRecipients(recipients []address, shares []int64) { if len(recipients) != len(shares) { panic("recipients and shares must have the same length") } if len(recipients) == 0 { panic("at least one recipient is required") } if len(recipients) > MaxRecipients { panic("too many recipients (max " + strconv.Itoa(MaxRecipients) + ")") } seen := make(map[address]bool) for _, r := range recipients { if seen[r] { panic("duplicate recipient: " + string(r)) } seen[r] = true } } // sortedBalanceAddrs returns the balance-map keys in deterministic order, // so panics and renders never depend on map iteration order. func sortedBalanceAddrs(s *Split) []string { addrs := make([]string, 0, len(s.Balances)) for a := range s.Balances { addrs = append(addrs, string(a)) } sort.Strings(addrs) return addrs } // rejectStraySend aborts when coins are attached to a call that does not // accept them — the abort reverts the transfer back to the sender instead // of stranding the coins on the realm address (re-audit P3). Direct bank // transfers to the realm address remain unrecoverable by design. func rejectStraySend(cur realm) { if !cur.IsCurrent() { // every call site passes the entrypoint's own first cur; this // pins that discipline against future call-site drift (v2 // audit G3 hardening) panic("realm capability is not current") } if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this function does not accept coins; attach coins to Deposit only") } } // ---------- write operations ---------- // CreateSplit registers a new split. The caller becomes the owner. // Recipients and shares are comma-separated; shares are in basis points // summing to 10000. func CreateSplit(cur realm, recipientList, shareList string) string { rejectStraySend(cur) owner := cur.Previous().Address() if ownerSplits[owner] >= MaxSplitsPerOwner { panic("per-owner split limit reached") } recipients := parseAddresses(recipientList) shares := parseBasisPoints(shareList) validateRecipients(recipients, shares) id := "split_" + strconv.Itoa(nextID) nextID++ balances := make(map[address]int64) for _, r := range recipients { balances[r] = 0 } splits[id] = &Split{ Owner: owner, Recipients: recipients, Shares: shares, Balances: balances, } splitIDs = append(splitIDs, id) ownerSplits[owner]++ return id } // Deposit distributes the coins sent with the call across recipients // proportionally. // // DEPLOYMENT PRECONDITION (round-4 audit): on a network with // restricted/token-locked ugnot transfers, the bank gate is // SENDER-whitelist-based — a whitelisted user's Deposit succeeds but // Claim sends FROM this realm's (non-whitelisted) address and reverts. // Funds would flow in and not out until the restriction lifts. Deploy // only to networks with unrestricted ugnot, or have governance // whitelist this realm's address first. // // LIMITATION (round-3 audit, documented): only direct user calls can // deposit. A DAO/realm treasury has NO deposit path — a realm-routed // call is refused, and a bare banker send to this realm's address is // an unrecoverable donation. Realm treasuries must route deposits // through a user account. The deposit is the ACTUAL attached send — exactly one // coin of Denom — so balances are always backed by funds this realm // holds. Direct user calls only: a deposit routed through an // intermediary realm would deliver its coins to that realm, not here, // and must be rejected. Rounding dust goes to the highest-share // recipient (deterministic, not order-dependent). func Deposit(cur realm, splitID string) { s := mustGetActive(splitID) if s.Frozen { panic("split is frozen") } // IsUserCall, not IsUser: MsgRun passes IsUser but its attached send // goes caller->caller — the coins never reach this realm, and OriginSend // could be re-read across k calls in one run script (re-audit P1). A // direct MsgCall's send provably lands on the called package address. if !cur.Previous().IsUserCall() { panic("deposits must be sent by direct call, not through another realm or a run script") } sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != Denom { panic("deposit must send exactly one coin of " + Denom) } amount := sent[0].Amount if amount <= 0 { panic("amount must be greater than zero") } if amount > MaxDepositAmount { panic("deposit exceeds maximum supported amount") } if s.TotalDeposited > int64(9223372036854775807)-amount { panic("deposit would overflow split accounting") } // Protocol fee comes off the top; everything below distributes the // NET amount, so the per-split conservation invariant // (sum(balances)+TotalClaimed == TotalDeposited) is untouched. // amount <= MaxDepositAmount and feeBps <= 100, so the product is // far below overflow. fee := (amount * feeBps) / 10000 if fee > 0 { if feesAccrued > int64(9223372036854775807)-fee { panic("fee accrual would overflow") } feesAccrued += fee amount -= fee } if amount == 0 { panic("deposit too small: fully consumed by the protocol fee") } s.TotalDeposited += amount // Find the highest-share recipient for dust assignment dustIdx := 0 for i := 1; i < len(s.Shares); i++ { if s.Shares[i] > s.Shares[dustIdx] { dustIdx = i } } var distributed int64 for i, r := range s.Recipients { share := (amount * s.Shares[i]) / 10000 s.Balances[r] += share distributed += share } // Assign dust to highest-share recipient dust := amount - distributed if dust > 0 { s.Balances[s.Recipients[dustIdx]] += dust } } // Claim withdraws the caller's accumulated balance and SENDS the coins // to the caller's address. Balance is zeroed before the transfer. // Claims remain possible on frozen splits, and by ex-recipients whose // accrued balance predates a share update. func Claim(cur realm, splitID string) int64 { rejectStraySend(cur) s := mustGetActive(splitID) addr := cur.Previous().Address() bal, exists := s.Balances[addr] if !exists { panic("not a recipient of this split") } if bal == 0 { panic("nothing to claim") } s.Balances[addr] = 0 s.TotalClaimed += bal b := banker.NewBanker(banker.BankerTypeRealmSend, cur) b.SendCoins(cur.Address(), addr, chain.Coins{{Denom: Denom, Amount: bal}}) return bal } // UpdateShares replaces recipients and shares. Owner only. Not if frozen. // Removed recipients keep any accrued balance and can still Claim it. func UpdateShares(cur realm, splitID, recipientList, shareList string) { rejectStraySend(cur) s := mustGetActive(splitID) if cur.Previous().Address() != s.Owner { panic("only the owner can update shares") } if s.Frozen { panic("split is frozen") } recipients := parseAddresses(recipientList) shares := parseBasisPoints(shareList) validateRecipients(recipients, shares) for _, r := range recipients { if _, ok := s.Balances[r]; !ok { s.Balances[r] = 0 } } s.Recipients = recipients s.Shares = shares } // TransferOwnership stages a two-step ownership handover; nothing // moves until the nominee calls AcceptOwnership. Two-step for the same // reason as the fee admin, plus one more (v2 audit Y1): a one-step // transfer consumed the RECIPIENT's per-owner quota without consent, // so poisoned splits could burn a victim's slots unarchivably. Staging // consumes nothing of the nominee's. Pass "" to clear a pending // nomination. func TransferOwnership(cur realm, splitID string, newOwner address) { rejectStraySend(cur) s := mustGetActive(splitID) if cur.Previous().Address() != s.Owner { panic("only the owner can transfer ownership") } if newOwner == "" { s.PendingOwner = "" return } if !newOwner.IsValid() || string(newOwner) != strings.ToLower(string(newOwner)) { // see parseAddresses: an uppercase owner could never match the // runtime-derived caller again — the split would be owner-less panic("invalid new owner address: " + string(newOwner)) } if newOwner == s.Owner { panic("new owner is already the owner") } s.PendingOwner = newOwner } // AcceptOwnership completes the handover; only the nominee can accept. // The nominee's quota is checked HERE, at consent time — the split // slot moves only with the acceptor's own signature (v2 audit Y1: a // recipient's quota is never consumed without consent). A nomination // on a split that is later archived is inert (mustGetActive). func AcceptOwnership(cur realm, splitID string) { rejectStraySend(cur) s := mustGetActive(splitID) caller := cur.Previous().Address() if s.PendingOwner == "" || caller != s.PendingOwner { panic("caller is not the pending owner") } if ownerSplits[caller] >= MaxSplitsPerOwner { panic("new owner is at the per-owner split limit") } ownerSplits[s.Owner]-- if ownerSplits[s.Owner] <= 0 { delete(ownerSplits, s.Owner) } ownerSplits[caller]++ s.Owner = caller s.PendingOwner = "" } // Freeze permanently locks shares and stops further deposits. One-way, // cannot be undone. Claims remain possible. func Freeze(cur realm, splitID string) { rejectStraySend(cur) s := mustGetActive(splitID) if cur.Previous().Address() != s.Owner { panic("only the owner can freeze") } s.Frozen = true } // Archive marks a fully-claimed split as archived. Only the owner can // archive, and only if EVERY balance — including balances held by // ex-recipients removed in a share update — is zero, since archiving // blocks all further claims. Cannot be undone. func Archive(cur realm, splitID string) { rejectStraySend(cur) s := mustGetActive(splitID) if cur.Previous().Address() != s.Owner { panic("only the owner can archive") } for _, a := range sortedBalanceAddrs(s) { if s.Balances[address(a)] > 0 { panic("cannot archive: outstanding balance for " + a) } } s.Archived = true // Free the owner's slot so they can create new splits ownerSplits[s.Owner]-- if ownerSplits[s.Owner] <= 0 { delete(ownerSplits, s.Owner) } // Remove from active ID list (keeps map entry for audit) for i, id := range splitIDs { if id == splitID { splitIDs = append(splitIDs[:i], splitIDs[i+1:]...) break } } } // ---------- protocol fee ---------- // SetFee sets the protocol fee in basis points, admin only, hard-capped // at MaxFeeBps. Applies to FUTURE deposits only. func SetFee(cur realm, bps int64) { rejectStraySend(cur) if cur.Previous().Address() != feeAdmin { panic("only the fee admin can set the fee") } if bps < 0 || bps > MaxFeeBps { panic("fee must be between 0 and " + strconv.FormatInt(MaxFeeBps, 10) + " basis points") } feeBps = bps } // ClaimFees sends all accrued protocol fees to the fee admin. func ClaimFees(cur realm) int64 { rejectStraySend(cur) if cur.Previous().Address() != feeAdmin { panic("only the fee admin can claim fees") } if feesAccrued == 0 { panic("no fees accrued") } amount := feesAccrued feesAccrued = 0 feesClaimed += amount b := banker.NewBanker(banker.BankerTypeRealmSend, cur) b.SendCoins(cur.Address(), feeAdmin, chain.Coins{{Denom: Denom, Amount: amount}}) return amount } // NominateFeeAdmin begins a two-step admin handover; the nominee must // AcceptFeeAdmin. Pass "" to clear a pending nomination. Two-step // because the admin address is a funds destination: a typo'd one-step // transfer would strand all future fees. func NominateFeeAdmin(cur realm, nominee address) { rejectStraySend(cur) if cur.Previous().Address() != feeAdmin { panic("only the fee admin can nominate a successor") } if nominee == "" { pendingFeeAdmin = "" return } if !nominee.IsValid() || string(nominee) != strings.ToLower(string(nominee)) { panic("invalid nominee address: " + string(nominee)) } pendingFeeAdmin = nominee } // AcceptFeeAdmin completes the handover; only the nominee can accept. func AcceptFeeAdmin(cur realm) { rejectStraySend(cur) if pendingFeeAdmin == "" || cur.Previous().Address() != pendingFeeAdmin { panic("caller is not the pending fee admin") } feeAdmin = pendingFeeAdmin pendingFeeAdmin = "" } // GetFeeInfo returns the current fee configuration and accrued total. func GetFeeInfo() string { return "fee: " + formatPct(feeBps) + " (cap " + formatPct(MaxFeeBps) + ") | admin: " + string(feeAdmin) + " | accrued: " + strconv.FormatInt(feesAccrued, 10) + " | claimed: " + strconv.FormatInt(feesClaimed, 10) } // ---------- read-only queries ---------- // GetSplitInfo returns a human-readable summary. func GetSplitInfo(splitID string) string { s := mustGetSplit(splitID) var b strings.Builder b.WriteString("Split: " + splitID + "\n") b.WriteString("Owner: " + string(s.Owner) + "\n") if s.PendingOwner != "" { // a nominee must be able to READ the offer before spending gas // probing it with AcceptOwnership (v2 audit Y1-obs) b.WriteString("Pending owner: " + string(s.PendingOwner) + "\n") } b.WriteString("Frozen: " + boolStr(s.Frozen) + "\n") b.WriteString("Archived: " + boolStr(s.Archived) + "\n") b.WriteString("Total deposited: " + strconv.FormatInt(s.TotalDeposited, 10) + "\n") b.WriteString("Total claimed: " + strconv.FormatInt(s.TotalClaimed, 10) + "\n") b.WriteString("Recipients:\n") for i, r := range s.Recipients { b.WriteString(" " + string(r) + " " + formatPct(s.Shares[i]) + " claimable: " + strconv.FormatInt(s.Balances[r], 10) + "\n") } return b.String() } // GetPendingOwner returns the staged nominee for a split, or "" when no // handover is pending. The consent half of a two-step transfer only means // something if the nominee can inspect the offer without paying gas to // probe it (v2 audit Y1-obs); this is that read path. func GetPendingOwner(splitID string) string { return string(mustGetSplit(splitID).PendingOwner) } // GetClaimable returns claimable balance for an address. func GetClaimable(splitID string, addr address) int64 { s := mustGetSplit(splitID) return s.Balances[addr] } // ---------- render ---------- // Render returns a markdown overview. Never panics. func Render(path string) string { if len(splitIDs) == 0 { return "# Fee Split\n\nNo active splits.\n" } var b strings.Builder b.WriteString("# Fee Split\n\n") if feeBps > 0 { b.WriteString("**Protocol fee:** " + formatPct(feeBps) + " (hard cap " + formatPct(MaxFeeBps) + ")\n\n") } show := splitIDs if len(show) > MaxRenderSplits { b.WriteString("_Showing the most recent " + strconv.Itoa(MaxRenderSplits) + " active splits._\n\n") show = show[len(show)-MaxRenderSplits:] } for _, id := range show { s := splits[id] b.WriteString("## " + id + "\n\n") if s == nil { b.WriteString("_(invalid)_\n\n") continue } b.WriteString("```\n") b.WriteString("Owner: " + string(s.Owner) + "\n") if s.PendingOwner != "" { b.WriteString("Pending owner: " + string(s.PendingOwner) + "\n") } b.WriteString("Frozen: " + boolStr(s.Frozen) + "\n") b.WriteString("Total deposited: " + strconv.FormatInt(s.TotalDeposited, 10) + "\n") b.WriteString("Total claimed: " + strconv.FormatInt(s.TotalClaimed, 10) + "\n") if len(s.Recipients) > 0 { b.WriteString("Recipients:\n") for i, r := range s.Recipients { share := "??%" if i < len(s.Shares) { share = formatPct(s.Shares[i]) } bal := "0" if s.Balances != nil { bal = strconv.FormatInt(s.Balances[r], 10) } b.WriteString(" " + string(r) + " " + share + " claimable: " + bal + "\n") } } b.WriteString("```\n\n") } return b.String() }
- #4fee_split_test.gno
- #5package fee_split import ( "chain" "chain/banker" "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( owner = testutils.TestAddress("owner") alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") attacker = testutils.TestAddress("attacker") ) // resetState resets all package-level state between tests. // Required because Gno test functions share the same VM context. func resetState() { splits = make(map[string]*Split) splitIDs = []string{} ownerSplits = make(map[address]int) nextID = 1 feeBps = 0 feeAdmin = feeAdminAddr pendingFeeAdmin = "" feesAccrued = 0 feesClaimed = 0 } var feeAdminAddr = testutils.TestAddress("feeadmin") func addrList(addrs ...address) string { parts := make([]string, len(addrs)) for i, a := range addrs { parts[i] = string(a) } return strings.Join(parts, ",") } // fund gives the realm's package address coins so Claim payouts succeed, // mirroring the deposits the accounting records. func fund(amount int64) { pkgAddr := chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/fee_split/v2") testing.IssueCoins(pkgAddr, chain.Coins{{Denom: Denom, Amount: amount}}) } // deposit performs a coin-backed deposit as addr. func deposit(cur realm, addr address, splitID string, amount int64) { testing.SetRealm(testing.NewUserRealm(addr)) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: amount}}) fund(amount) Deposit(cross(cur), splitID) testing.SetOriginSend(nil) } // ---------- CreateSplit ---------- func TestCreateSplit_Basic(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "6000,4000") uassert.Equal(t, "split_1", id) s := splits["split_1"] uassert.Equal(t, string(owner), string(s.Owner)) uassert.Equal(t, 2, len(s.Recipients)) uassert.Equal(t, string(alice), string(s.Recipients[0])) uassert.Equal(t, string(bob), string(s.Recipients[1])) uassert.Equal(t, int64(6000), s.Shares[0]) uassert.Equal(t, int64(4000), s.Shares[1]) uassert.False(t, s.Frozen, "new split must not be frozen") uassert.Equal(t, int64(0), s.TotalDeposited) uassert.Equal(t, int64(0), s.TotalClaimed) } func TestCreateSplit_IDsIncrement(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id1 := CreateSplit(cross(cur), addrList(alice), "10000") id2 := CreateSplit(cross(cur), addrList(bob), "10000") uassert.Equal(t, "split_1", id1) uassert.Equal(t, "split_2", id2) } func TestCreateSplit_DuplicateRecipient(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "duplicate recipient: "+string(alice), func() { CreateSplit(cross(cur), addrList(alice, alice), "5000,5000") }, ) } func TestCreateSplit_SharesDontSum(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "shares must sum to 10000 basis points, got 5000", func() { CreateSplit(cross(cur), addrList(alice), "5000") }, ) } func TestCreateSplit_LengthMismatch(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "recipients and shares must have the same length", func() { CreateSplit(cross(cur), addrList(alice, bob), "10000") }, ) } func TestCreateSplit_InvalidAddressRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "invalid recipient address: not_an_address", func() { CreateSplit(cross(cur), "not_an_address", "10000") }, ) } func TestCreateSplit_PerOwnerLimit(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) for i := 0; i < MaxSplitsPerOwner; i++ { CreateSplit(cross(cur), addrList(alice), "10000") } uassert.AbortsWithMessage(t, cur, "per-owner split limit reached", func() { CreateSplit(cross(cur), addrList(alice), "10000") }, ) } // ---------- Deposit (coin-backed) ---------- func TestDeposit_UsesSentCoins(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 1000) s := splits[id] uassert.Equal(t, int64(1000), s.TotalDeposited) uassert.Equal(t, int64(500), s.Balances[alice]) uassert.Equal(t, int64(500), s.Balances[bob]) } func TestDeposit_NoCoinsPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") // no SetOriginSend: nothing attached to the call testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "deposit must send exactly one coin of "+Denom, func() { Deposit(cross(cur), id) }, ) uassert.Equal(t, int64(0), splits[id].TotalDeposited) } func TestDeposit_WrongDenomPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") testing.SetOriginSend(chain.Coins{{Denom: "ufoo", Amount: 100}}) uassert.AbortsWithMessage(t, cur, "deposit must send exactly one coin of "+Denom, func() { Deposit(cross(cur), id) }, ) testing.SetOriginSend(nil) } func TestDeposit_RoundingDustGoesToHighestShare(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob, carol), "3334,3333,3333") deposit(cur, owner, id, 10) s := splits[id] uassert.Equal(t, int64(10), s.TotalDeposited) // alice: (10*3334)/10000 = 3, +1 dust (highest share) uassert.Equal(t, int64(4), s.Balances[alice]) uassert.Equal(t, int64(3), s.Balances[bob]) uassert.Equal(t, int64(3), s.Balances[carol]) } func TestDeposit_AccumulatesAcrossMultipleDeposits(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 100) deposit(cur, owner, id, 200) s := splits[id] uassert.Equal(t, int64(300), s.TotalDeposited) uassert.Equal(t, int64(150), s.Balances[alice]) uassert.Equal(t, int64(150), s.Balances[bob]) } func TestDeposit_OverflowGuard(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: MaxDepositAmount + 1}}) uassert.AbortsWithMessage(t, cur, "deposit exceeds maximum supported amount", func() { Deposit(cross(cur), id) }, ) testing.SetOriginSend(nil) uassert.Equal(t, int64(0), splits[id].TotalDeposited) } func TestDeposit_FrozenSplitPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") Freeze(cross(cur), id) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 100}}) uassert.AbortsWithMessage(t, cur, "split is frozen", func() { Deposit(cross(cur), id) }, ) testing.SetOriginSend(nil) } func TestDeposit_UnknownSplitPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "split not found: split_99", func() { Deposit(cross(cur), "split_99") }, ) } // ---------- Claim (pays out coins) ---------- func TestClaim_SendsCoins(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "7000,3000") deposit(cur, owner, id, 10000) before := banker.NewReadonlyBanker().GetCoins(alice).AmountOf(Denom) testing.SetRealm(testing.NewUserRealm(alice)) claimed := Claim(cross(cur), id) uassert.Equal(t, int64(7000), claimed) after := banker.NewReadonlyBanker().GetCoins(alice).AmountOf(Denom) uassert.Equal(t, int64(7000), after-before) s := splits[id] uassert.Equal(t, int64(0), s.Balances[alice]) uassert.Equal(t, int64(7000), s.TotalClaimed) } func TestClaim_CallerIdentityIsEnforced(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) // the attacker cannot claim alice's balance: identity comes from the // chain, not from an argument testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "not a recipient of this split", func() { Claim(cross(cur), id) }, ) uassert.Equal(t, int64(100), splits[id].Balances[alice]) } func TestClaim_NothingToClaim(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "nothing to claim", func() { Claim(cross(cur), id) }, ) } func TestClaim_CannotClaimTwice(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) testing.SetRealm(testing.NewUserRealm(alice)) Claim(cross(cur), id) uassert.AbortsWithMessage(t, cur, "nothing to claim", func() { Claim(cross(cur), id) }, ) } func TestClaim_ExRecipientKeepsAccruedBalance(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 1000) testing.SetRealm(testing.NewUserRealm(owner)) UpdateShares(cross(cur), id, addrList(carol, dave), "6000,4000") // bob was removed but keeps — and can claim — his accrued 500 testing.SetRealm(testing.NewUserRealm(bob)) claimed := Claim(cross(cur), id) uassert.Equal(t, int64(500), claimed) } // ---------- UpdateShares ---------- func TestUpdateShares_Basic(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") UpdateShares(cross(cur), id, addrList(alice, carol), "7000,3000") s := splits[id] uassert.Equal(t, 2, len(s.Recipients)) uassert.Equal(t, string(alice), string(s.Recipients[0])) uassert.Equal(t, string(carol), string(s.Recipients[1])) uassert.Equal(t, int64(7000), s.Shares[0]) uassert.Equal(t, int64(3000), s.Shares[1]) } func TestUpdateShares_PreservesUnclaimedBalances(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 1000) testing.SetRealm(testing.NewUserRealm(owner)) UpdateShares(cross(cur), id, addrList(carol, dave), "6000,4000") s := splits[id] uassert.Equal(t, int64(500), s.Balances[alice]) uassert.Equal(t, int64(500), s.Balances[bob]) } func TestUpdateShares_WrongOwner(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the owner can update shares", func() { UpdateShares(cross(cur), id, addrList(alice), "10000") }, ) } func TestUpdateShares_FrozenSplitPanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") Freeze(cross(cur), id) uassert.AbortsWithMessage(t, cur, "split is frozen", func() { UpdateShares(cross(cur), id, addrList(alice, bob), "5000,5000") }, ) } // ---------- TransferOwnership / AcceptOwnership (two-step, v2 Y1) ---------- func TestTransferOwnership_TwoStep(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) // staging moves NOTHING: owner, both quotas unchanged uassert.Equal(t, string(owner), string(splits[id].Owner)) uassert.Equal(t, string(bob), string(splits[id].PendingOwner)) uassert.Equal(t, 1, ownerSplits[owner]) uassert.Equal(t, 0, ownerSplits[bob]) testing.SetRealm(testing.NewUserRealm(bob)) AcceptOwnership(cross(cur), id) uassert.Equal(t, string(bob), string(splits[id].Owner)) uassert.Equal(t, "", string(splits[id].PendingOwner)) } func TestTransferOwnership_WrongOwner(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the owner can transfer ownership", func() { TransferOwnership(cross(cur), id, attacker) }, ) } func TestAcceptOwnership_OnlyNominee(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) // a non-nominee cannot accept — including with no nomination staged testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner", func() { AcceptOwnership(cross(cur), id) }, ) // the owner clears the nomination; the ex-nominee can no longer accept testing.SetRealm(testing.NewUserRealm(owner)) TransferOwnership(cross(cur), id, "") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner", func() { AcceptOwnership(cross(cur), id) }, ) } func TestAcceptOwnership_MovesOwnerSlotAtConsent(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") uassert.Equal(t, 1, ownerSplits[owner]) TransferOwnership(cross(cur), id, bob) testing.SetRealm(testing.NewUserRealm(bob)) AcceptOwnership(cross(cur), id) // the creator's slot is freed; the acceptor's is consumed uassert.Equal(t, 0, ownerSplits[owner]) uassert.Equal(t, 1, ownerSplits[bob]) // and the creator can fill the freed slot again testing.SetRealm(testing.NewUserRealm(owner)) for i := 0; i < MaxSplitsPerOwner; i++ { CreateSplit(cross(cur), addrList(alice), "10000") } } func TestAcceptOwnership_AcceptorAtLimitBlocked(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(bob)) for i := 0; i < MaxSplitsPerOwner; i++ { CreateSplit(cross(cur), addrList(alice), "10000") } testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) // the quota gate fires at ACCEPT, against the acceptor's own count testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "new owner is at the per-owner split limit", func() { AcceptOwnership(cross(cur), id) }, ) } // The v2 audit Y1 griefing scenario is dead: staging a transfer at a // victim consumes none of the victim's quota and changes nothing the // victim must act on. func TestTransferOwnership_UnconsentedGriefDefused(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(attacker)) for i := 0; i < MaxSplitsPerOwner; i++ { id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) // staged at the victim, never accepted } // victim quota untouched; victim can still create a full quota of splits uassert.Equal(t, 0, ownerSplits[bob]) testing.SetRealm(testing.NewUserRealm(bob)) for i := 0; i < MaxSplitsPerOwner; i++ { CreateSplit(cross(cur), addrList(alice), "10000") } // while the attacker's own budget stays consumed — as documented uassert.Equal(t, MaxSplitsPerOwner, ownerSplits[attacker]) } // Self-transfer is refused at STAGE time. This guard is what makes // self-accept unreachable, and so what keeps AcceptOwnership's -1/+1 // quota pair from collapsing onto one address (v2 audit G-7.1). func TestTransferOwnership_SelfRefused(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") uassert.AbortsWithMessage(t, cur, "new owner is already the owner", func() { TransferOwnership(cross(cur), id, owner) }, ) uassert.Equal(t, 1, ownerSplits[owner]) } // Re-staging overwrites the prior nomination; only the latest nominee // can accept (v2 audit G-7.4). func TestTransferOwnership_RestageOverwrites(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) TransferOwnership(cross(cur), id, alice) uassert.Equal(t, string(alice), GetPendingOwner(id)) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner", func() { AcceptOwnership(cross(cur), id) }, ) testing.SetRealm(testing.NewUserRealm(alice)) AcceptOwnership(cross(cur), id) uassert.Equal(t, string(alice), string(splits[id].Owner)) } // A nomination on a split that is later archived is inert — the doc // comment on AcceptOwnership claims it, this pins it (v2 audit G-7.2). func TestAcceptOwnership_ArchivedRefused(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) Archive(cross(cur), id) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "split is archived: "+id, func() { AcceptOwnership(cross(cur), id) }, ) // the archived split's slot stays returned to its creator, not the nominee uassert.Equal(t, 0, ownerSplits[owner]) uassert.Equal(t, 0, ownerSplits[bob]) } // Freezing locks shares and deposits, not custody: a frozen split stays // transferable so its owner can hand off claim-servicing duty // (v2 audit G-7.3). func TestAcceptOwnership_FrozenAllowed(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") TransferOwnership(cross(cur), id, bob) Freeze(cross(cur), id) testing.SetRealm(testing.NewUserRealm(bob)) AcceptOwnership(cross(cur), id) uassert.Equal(t, string(bob), string(splits[id].Owner)) uassert.True(t, splits[id].Frozen) } // The nominee can read the offer without paying gas to probe it, and the // pending line disappears once consumed or cleared (v2 audit Y1-obs). func TestPendingOwner_ReadSurface(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") uassert.Equal(t, "", GetPendingOwner(id)) uassert.False(t, strings.Contains(GetSplitInfo(id), "Pending owner:")) TransferOwnership(cross(cur), id, bob) uassert.Equal(t, string(bob), GetPendingOwner(id)) uassert.True(t, strings.Contains(GetSplitInfo(id), "Pending owner: "+string(bob))) uassert.True(t, strings.Contains(Render(""), "Pending owner: "+string(bob))) testing.SetRealm(testing.NewUserRealm(bob)) AcceptOwnership(cross(cur), id) uassert.Equal(t, "", GetPendingOwner(id)) uassert.False(t, strings.Contains(GetSplitInfo(id), "Pending owner:")) } // ---------- Freeze ---------- func TestFreeze_Basic(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") Freeze(cross(cur), id) uassert.True(t, splits[id].Frozen, "split must be frozen after Freeze()") } func TestFreeze_WrongOwner(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the owner can freeze", func() { Freeze(cross(cur), id) }, ) } func TestFreeze_ClaimsStillWork(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) testing.SetRealm(testing.NewUserRealm(owner)) Freeze(cross(cur), id) testing.SetRealm(testing.NewUserRealm(alice)) uassert.Equal(t, int64(100), Claim(cross(cur), id)) } // ---------- Archive ---------- func TestArchive_BlocksWithOutstandingBalance(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "cannot archive: outstanding balance for "+string(alice), func() { Archive(cross(cur), id) }, ) } func TestArchive_BlocksOnExRecipientBalance(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 1000) testing.SetRealm(testing.NewUserRealm(owner)) // remove bob; his 500 stays claimable UpdateShares(cross(cur), id, addrList(alice), "10000") testing.SetRealm(testing.NewUserRealm(alice)) Claim(cross(cur), id) // archive must refuse while the EX-recipient still has a balance — // archiving would lock his claim forever testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsWithMessage(t, cur, "cannot archive: outstanding balance for "+string(bob), func() { Archive(cross(cur), id) }, ) // once bob claims, archive succeeds testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), id) testing.SetRealm(testing.NewUserRealm(owner)) Archive(cross(cur), id) uassert.True(t, splits[id].Archived, "split must be archived") } // ---------- queries ---------- func TestGetClaimable(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "8000,2000") deposit(cur, owner, id, 5000) uassert.Equal(t, int64(4000), GetClaimable(id, alice)) uassert.Equal(t, int64(1000), GetClaimable(id, bob)) uassert.Equal(t, int64(0), GetClaimable(id, attacker)) } func TestGetSplitInfo_ContainsExpectedFields(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 200) info := GetSplitInfo(id) uassert.True(t, strings.Contains(info, "Owner"), "GetSplitInfo output must contain 'Owner'") uassert.True(t, strings.Contains(info, string(owner)), "GetSplitInfo output must contain owner address") uassert.True(t, strings.Contains(info, string(alice)), "GetSplitInfo output must contain recipient") } // ---------- render ---------- func TestRender_EmptyState(cur realm, t *testing.T) { resetState() out := Render("") uassert.Equal(t, "# Fee Split\n\nNo active splits.\n", out) } func TestRender_WithSplits(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) CreateSplit(cross(cur), addrList(alice, bob), "6000,4000") deposit(cur, owner, "split_1", 1000) out := Render("") uassert.True(t, strings.Contains(out, "split_1"), "Render output must reference split_1") uassert.True(t, strings.Contains(out, string(owner)), "Render output must include owner") } // ---------- re-audit regressions (2026-09-02) ---------- func TestCreateSplit_ShareSumWraparoundRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) // re-audit P1: these shares sum past 2^64 back to exactly 10000; the // per-share <=10000 bound must refuse them before the sum can wrap uassert.AbortsWithMessage(t, cur, "invalid share value: 1152921505606846976", func() { CreateSplit(cross(cur), addrList(alice, bob, carol), "1152921505606846976,8646911284051357320,8646911284051357320") }, ) uassert.AbortsWithMessage(t, cur, "invalid share value: 10001", func() { CreateSplit(cross(cur), addrList(alice, bob), "10001,10000") }, ) } func TestArchiveFreesActiveListAndOwnerSlot(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") Archive(cross(cur), id) uassert.Equal(t, 1, len(splits), "archived entry stays for audit") uassert.Equal(t, 0, len(splitIDs), "archived entry frees the active list") CreateSplit(cross(cur), addrList(alice), "10000") // must not abort } func TestNoGlobalCap_SybilsCannotBlockOthers(cur realm, t *testing.T) { resetState() // round-4 P2: with per-owner quotas only, other owners filling their // own budgets never block a legitimate creator for i := 0; i < 30; i++ { syb := testutils.TestAddress("sybil_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(syb)) for j := 0; j < MaxSplitsPerOwner; j++ { CreateSplit(cross(cur), addrList(alice), "10000") } } uassert.Equal(t, 300, len(splitIDs)) testing.SetRealm(testing.NewUserRealm(owner)) CreateSplit(cross(cur), addrList(alice), "10000") // must not abort } func TestPreParseInputBounds(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) // round-4 P2: oversized inputs must be refused BEFORE parsing work uassert.AbortsWithMessage(t, cur, "recipient list too long", func() { CreateSplit(cross(cur), strings.Repeat(",", MaxRecipientListLen+1), "10000") }, ) uassert.AbortsWithMessage(t, cur, "share list too long", func() { CreateSplit(cross(cur), addrList(alice), strings.Repeat("1,", MaxShareListLen)) }, ) } func TestStraySendRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) // re-audit P3: coins attached to a non-Deposit call must abort (and // therefore revert to the sender), never strand on the realm address testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 50}}) uassert.AbortsWithMessage(t, cur, "this function does not accept coins; attach coins to Deposit only", func() { Claim(cross(cur), id) }, ) testing.SetOriginSend(nil) // without coins attached the same claim succeeds uassert.Equal(t, int64(100), Claim(cross(cur), id)) } func TestDeposit_CodeRealmRefused(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") // re-audit P1 companion: only a direct user MsgCall may deposit — a // code realm as previous realm must be refused (its send would not // have landed here). The MsgRun/ephemeral shape (the other half of // the IsUserCall-not-IsUser rationale) cannot be constructed in this // driver: SetRealm accepts only user realms and /r/ code realms // (NewCodeRealm rejects /e/ paths), so the predicate choice is // pinned here via the code-realm branch and verified live on-chain. testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/intermediary")) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 100}}) uassert.AbortsContains(t, cur, "deposits must be sent by direct call", func() { Deposit(cross(cur), id) }, ) testing.SetOriginSend(nil) } // ---------- round-3 audit regressions (2026-09-02) ---------- func TestUppercaseAddressesRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) // round-3 P2: all-uppercase bech32 passes IsValid but can never // match caller(): as a recipient it locks funds and blocks Archive; // as upper/lower twins it bypasses the duplicate check; as an owner // it orphans the split upper := strings.ToUpper(string(alice)) uassert.AbortsWithMessage(t, cur, "invalid recipient address: "+upper, func() { CreateSplit(cross(cur), string(alice)+","+upper, "5000,5000") }, ) id := CreateSplit(cross(cur), addrList(alice), "10000") uassert.AbortsWithMessage(t, cur, "invalid new owner address: "+upper, func() { TransferOwnership(cross(cur), id, address(upper)) }, ) } func TestRender_OnlyArchivedSplits(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") Archive(cross(cur), id) uassert.Equal(t, "# Fee Split\n\nNo active splits.\n", Render(""), "archived-only state must render as no active splits") } // ---------- protocol fee (2026-09-03) ---------- func TestFee_DefaultZeroAndCap(cur realm, t *testing.T) { resetState() uassert.Equal(t, int64(0), feeBps) testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) uassert.AbortsContains(t, cur, "fee must be between 0 and", func() { SetFee(cross(cur), MaxFeeBps+1) }) uassert.AbortsContains(t, cur, "fee must be between 0 and", func() { SetFee(cross(cur), -1) }) SetFee(cross(cur), MaxFeeBps) // exactly the cap is allowed uassert.Equal(t, MaxFeeBps, feeBps) testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the fee admin can set the fee", func() { SetFee(cross(cur), 1) }) } func TestFee_TakenFromDepositNetAccounting(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) SetFee(cross(cur), 50) // 0.5% testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") deposit(cur, owner, id, 10000) s := splits[id] // fee 50 off the top; 9950 net distributed (4975 + 4975) uassert.Equal(t, int64(50), feesAccrued) uassert.Equal(t, int64(9950), s.TotalDeposited) uassert.Equal(t, int64(4975), s.Balances[alice]) uassert.Equal(t, int64(4975), s.Balances[bob]) // conservation on net amounts holds untouched uassert.Equal(t, s.TotalDeposited, s.Balances[alice]+s.Balances[bob]+s.TotalClaimed) } func TestFee_NeverRetroactive(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 10000) // fee 0 at deposit time testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) SetFee(cross(cur), MaxFeeBps) // the previously credited balance is untouched by the fee change uassert.Equal(t, int64(10000), splits[id].Balances[alice]) testing.SetRealm(testing.NewUserRealm(alice)) uassert.Equal(t, int64(10000), Claim(cross(cur), id)) } func TestFee_ClaimFeesSendsCoins(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) SetFee(cross(cur), 100) // 1% testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 50000) // fee 500 testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the fee admin can claim fees", func() { ClaimFees(cross(cur)) }) before := banker.NewReadonlyBanker().GetCoins(feeAdminAddr).AmountOf(Denom) testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) uassert.Equal(t, int64(500), ClaimFees(cross(cur))) after := banker.NewReadonlyBanker().GetCoins(feeAdminAddr).AmountOf(Denom) uassert.Equal(t, int64(500), after-before) uassert.Equal(t, int64(0), feesAccrued) uassert.Equal(t, int64(500), feesClaimed) uassert.AbortsWithMessage(t, cur, "no fees accrued", func() { ClaimFees(cross(cur)) }) } func TestFee_TinyDepositFullyConsumedRefused(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) SetFee(cross(cur), MaxFeeBps) // 1% testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") // amounts < 100 at 1% round the fee to 0 — deposit passes whole deposit(cur, owner, id, 99) uassert.Equal(t, int64(0), feesAccrued) uassert.Equal(t, int64(99), splits[id].Balances[alice]) } func TestFee_TwoStepAdminHandover(cur realm, t *testing.T) { resetState() newAdmin := testutils.TestAddress("newfeeadmin") testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the fee admin can nominate a successor", func() { NominateFeeAdmin(cross(cur), attacker) }) testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) uassert.AbortsContains(t, cur, "invalid nominee address", func() { NominateFeeAdmin(cross(cur), address("JUNK")) }) NominateFeeAdmin(cross(cur), newAdmin) // nomination alone changes nothing uassert.Equal(t, string(feeAdminAddr), string(feeAdmin)) testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "caller is not the pending fee admin", func() { AcceptFeeAdmin(cross(cur)) }) testing.SetRealm(testing.NewUserRealm(newAdmin)) AcceptFeeAdmin(cross(cur)) uassert.Equal(t, string(newAdmin), string(feeAdmin)) uassert.Equal(t, "", string(pendingFeeAdmin)) // clearing a nomination works testing.SetRealm(testing.NewUserRealm(newAdmin)) NominateFeeAdmin(cross(cur), feeAdminAddr) NominateFeeAdmin(cross(cur), "") testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) uassert.AbortsWithMessage(t, cur, "caller is not the pending fee admin", func() { AcceptFeeAdmin(cross(cur)) }) } func TestFee_StraySendRejectedOnFeeFunctions(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 5}}) uassert.AbortsWithMessage(t, cur, "this function does not accept coins; attach coins to Deposit only", func() { SetFee(cross(cur), 10) }, ) testing.SetOriginSend(nil) } // ---------- round-5 audit regressions (fee path, 2026-09-03) ---------- // Pins the benign-degradation safety property: an empty fee admin (the // gno-test-VM init state, and any hypothetical empty-origin deploy) // leaves the fee permanently disabled and zero funds at risk — SetFee // can never be authorized, so feeBps stays 0 and nothing accrues. func TestFee_EmptyAdminDegradesToDisabled(cur realm, t *testing.T) { resetState() feeAdmin = "" feeBps = 0 for _, who := range []address{owner, alice, feeAdminAddr, attacker} { testing.SetRealm(testing.NewUserRealm(who)) uassert.AbortsWithMessage(t, cur, "only the fee admin can set the fee", func() { SetFee(cross(cur), 100) }) } uassert.Equal(t, int64(0), feeBps, "fee must remain 0 with no reachable admin") // a deposit accrues nothing testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 10000) uassert.Equal(t, int64(0), feesAccrued) testing.SetRealm(testing.NewUserRealm(alice)) uassert.Equal(t, int64(10000), Claim(cross(cur), id)) // full amount, no fee } // Fees accrued before a handover are claimable by the NEW admin // (documented semantics: claim before transferring if that matters). func TestFee_HandoverTransfersAccruedClaimRight(cur realm, t *testing.T) { resetState() newFa := testutils.TestAddress("newfa") testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) SetFee(cross(cur), 100) testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100000) // 1000 accrued under the original admin testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) NominateFeeAdmin(cross(cur), newFa) testing.SetRealm(testing.NewUserRealm(newFa)) AcceptFeeAdmin(cross(cur)) uassert.Equal(t, int64(1000), ClaimFees(cross(cur)), "new admin claims prior-regime fees") // the old admin can no longer claim — fails the authorization check testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) uassert.AbortsWithMessage(t, cur, "only the fee admin can claim fees", func() { ClaimFees(cross(cur)) }) } // Every fee function rejects an attached send (only SetFee was pinned). func TestFee_AllFunctionsRejectStraySend(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(feeAdminAddr)) send := chain.Coins{{Denom: Denom, Amount: 3}} msg := "this function does not accept coins; attach coins to Deposit only" testing.SetOriginSend(send) uassert.AbortsWithMessage(t, cur, msg, func() { SetFee(cross(cur), 10) }) uassert.AbortsWithMessage(t, cur, msg, func() { NominateFeeAdmin(cross(cur), attacker) }) uassert.AbortsWithMessage(t, cur, msg, func() { ClaimFees(cross(cur)) }) testing.SetOriginSend(nil) // AcceptFeeAdmin from the pending side, also with a send NominateFeeAdmin(cross(cur), attacker) testing.SetRealm(testing.NewUserRealm(attacker)) testing.SetOriginSend(send) uassert.AbortsWithMessage(t, cur, msg, func() { AcceptFeeAdmin(cross(cur)) }) testing.SetOriginSend(nil) } // ---------- audit round-1 gap closures (2026-09-21) ---------- func TestStraySendRejectedOnAllSplitFunctions(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") // audit Y2 gap 2: only Claim and the fee functions were pinned; the // five split-management functions call the same guard — prove it msg := "this function does not accept coins; attach coins to Deposit only" testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 7}}) uassert.AbortsWithMessage(t, cur, msg, func() { CreateSplit(cross(cur), addrList(carol), "10000") }) uassert.AbortsWithMessage(t, cur, msg, func() { UpdateShares(cross(cur), id, addrList(alice), "10000") }) uassert.AbortsWithMessage(t, cur, msg, func() { TransferOwnership(cross(cur), id, carol) }) uassert.AbortsWithMessage(t, cur, msg, func() { AcceptOwnership(cross(cur), id) }) uassert.AbortsWithMessage(t, cur, msg, func() { Freeze(cross(cur), id) }) uassert.AbortsWithMessage(t, cur, msg, func() { Archive(cross(cur), id) }) testing.SetOriginSend(nil) } func TestArchivedSplitRefusesDepositAndClaim(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") deposit(cur, owner, id, 100) testing.SetRealm(testing.NewUserRealm(alice)) Claim(cross(cur), id) testing.SetRealm(testing.NewUserRealm(owner)) Archive(cross(cur), id) // audit Y2 gap 3: the terminal state must refuse both value paths testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 50}}) uassert.AbortsWithMessage(t, cur, "split is archived: "+id, func() { Deposit(cross(cur), id) }) testing.SetOriginSend(nil) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "split is archived: "+id, func() { Claim(cross(cur), id) }) } func TestDeposit_TwoCoinEnvelopeRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice), "10000") // audit Y2 gap 4: only zero-coin and wrong-denom were pinned; a // two-coin envelope must also be refused by the exact len==1 check testing.SetOriginSend(chain.Coins{{Denom: Denom, Amount: 100}, {Denom: "ufoo", Amount: 1}}) uassert.AbortsWithMessage(t, cur, "deposit must send exactly one coin of "+Denom, func() { Deposit(cross(cur), id) }) testing.SetOriginSend(nil) uassert.Equal(t, int64(0), splits[id].TotalDeposited) } func TestDust_EqualShareTieGoesToFirst(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(owner)) id := CreateSplit(cross(cur), addrList(alice, bob), "5000,5000") // audit Y2 gap 5: the dust rule at an exact share tie is // first-listed-wins (strict > never replaces dustIdx) — pin it deposit(cur, owner, id, 101) uassert.Equal(t, int64(51), splits[id].Balances[alice]) uassert.Equal(t, int64(50), splits[id].Balances[bob]) }
- #6gnomod.toml
- #7module = "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/fee_split/v2" gno = "0.9"
- #8/gno.MemPackageType
- #9 MPUserAll
Result log
msg:0,success:true,log:,events:[]