Transaction

D6D76081009358…68011B2DDE5E

Block 245,194 · index 1 · indexed

Summary

Hash
D6D76081009358C1DA1D6959EF9A1D132C0C139F39A0F0385A9F68011B2DDE5E
Block
245,194
Size
37127 bytes
Gas used
47,707,065 / 66,124,800
Fee
661248ugnot
Status
success

Messages

#1AddPackagegno.land/r/moul/faucet/v015 arguments
Attached funds
15000000ugnot

Arguments · 15

  1. #1faucet
  2. #2README.md
  3. #3# GNOT faucet A two-step, on-the-record way to get a small amount of GNOT to someone who has none: anyone files a request on their behalf, an approver releases it, and both halves stay on the page with a reason attached. It exists because the accounts that most need a first coin are exactly the ones that cannot ask for it. An empty account cannot pay the gas to call anything, so `Request` takes the recipient as an argument and the filer pays for the ask. This is not the grant board. The grant board weighs work against milestones and proofs; this hands over pocket change so somebody can try the chain at all. ## The float The faucet spends only what has been sent to its own package address, never the approver's balance. Top it up with a plain bank send to that address, or with `Fund` if you want the donation on the record. `Withdraw` takes it back, to the owner and nowhere else. ## The two calls, and why they travel together | call | who | what it does | |---|---|---| | `Request(to, amount, reason)` | anyone | files an ask, returns its id, moves no money | | `Approve(id, to, amount)` | an approver | pays it out of the float | | `Deny(id, why)` | an approver | closes it unpaid, with the reason on the page | | `Fund()` | anyone, payable | credits the float and emits an event | | `Withdraw(amount)` | the owner | returns float to the owner | | `AddApprover` / `RemoveApprover` / `SetMaxPerRequest` | the owner | the knobs | A tm2 transaction carries a list of messages, runs them in order, and stops at the first failure; a failed transaction writes none of their state, only the fee and the sequence survive. So `Request` and `Approve` sent as two messages of one transaction either both happen or neither does, and the common case is a single signature. ### The id the second message cannot know `Approve` names a request by id, and message 2 of a transaction cannot read what message 1 returned. The caller reads `NextID` before signing and writes that number into the approval, which is a race: another request landing in between shifts the id, and the approval would pay a stranger. That is why `Approve` also takes the recipient and the amount it believes it is approving, and aborts when the stored request disagrees. The race then costs a failed transaction instead of the wrong person's money. ## Calls ```sh # Ask, on someone else's behalf. Amount is in ugnot. gnokey maketx call -pkgpath "gno.land/r/moul/faucet/v0" -func Request \ -args "g1..." -args 100000000 -args "no gas, wants to try the chain" \ -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid gnoland-1 moul # What id the next request will get, so an approval can be signed alongside it. gnokey query vm/qeval -data 'gno.land/r/moul/faucet/v0.NextID()' # What the float holds. gnokey query vm/qeval -data 'gno.land/r/moul/faucet/v0.Balance()' # The page. gnokey query vm/qrender -data 'gno.land/r/moul/faucet/v0:' ``` ## Reading it `Render("")` is the board: float, open requests, decided ones, approvers. `Render("req/<id>")` is one request, with its reason and, if it was refused, why. Every caller-supplied string on those pages is escaped before it is rendered. A reason is arbitrary text from an arbitrary account and `Render` output is markdown that gnoweb parses, so an unescaped one can plant a link, an image beacon or page chrome on a page the realm signs for. This path is permanent, so that bug could only ever be fixed at a new path. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/faucet/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/faucet/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4faucet.gno
  5. #5// Package faucet hands a small amount of GNOT to someone who has none, on the // record, in two calls that are meant to travel in one transaction. // // The chain's own faucet is gated and the people who most need a first coin // are exactly the people who cannot ask for one: an empty account cannot pay // the gas to call anything. So somebody else files the request on their // behalf, an approver releases it, and both halves are on chain with a reason // attached. // // # Why two calls and not one bank send // // [Request] is permissionless and [Approve] is not. Splitting them puts the // ask, its reason and who made it on chain even when the answer is no, and // makes every payout name the request it settles. A plain send would leave a // transfer with no why, and no way to refuse one in public. // // They are meant to be sent together. A tm2 transaction carries a LIST of // messages, runs them in order and stops at the first failure, and a failed // transaction writes none of their state: only the fee and the sequence // survive (tm2/pkg/sdk/baseapp.go, runMsgs and WriteCheckpoint). So a request // and its approval in one transaction either both happen or neither does. // // # The id the second message cannot know yet // // [Approve] names a request by id, and message 2 of a transaction cannot read // what message 1 returned. A caller therefore reads [NextID] first and writes // that number into the approval, which is a race: another Request landing in // between shifts the id under it, and the approval would pay a stranger. // // That is why [Approve] also takes the recipient and the amount it believes it // is approving, and refuses when the stored request disagrees. The race then // costs a failed transaction instead of the wrong person's rent. // // # What bounds the damage // // The faucet spends only what has been sent to its own address, never the // approver's balance, so the float is the ceiling and topping it up is a // deliberate act. [MaxPerRequest] caps any single payout under that, and // [Withdraw] takes the float back. package faucet import ( "strconv" "strings" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/nt/avl/v0" ) const ( // Denom is the only coin this faucet holds or pays. Denom = "ugnot" // Path is this realm's package path; its float is held at the address // derived from it. Path = "gno.land/r/moul/faucet/v0" // Link is Path as a gnoweb route. Link = "/r/moul/faucet/v0" ) // Owner funds the faucet, approves by default, and is the only account that // can change who else approves or take the float back. // // Hardcoded rather than captured from the deployer: inside a plain // `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the // EMPTY address, so an owner seeded from it is empty in every test and // something else on chain. That divergence is where an authorization bug // hides, so the address is written down. const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // The three states a request can be in. A request is decided exactly once. const ( StatusPending = "pending" StatusSent = "sent" StatusDenied = "denied" ) const ( // DefaultMaxPerRequest is the starting cap on a single payout, 200 GNOT. // It is a guard against a fat finger, not against a hostile approver: // an approver can raise nothing, but the Owner can. DefaultMaxPerRequest = 200_000_000 // MaxReasonLen bounds the stored reason. It is rendered on a page, so it // is both a storage cost and a display one. MaxReasonLen = 280 // idWidth pads the avl key. gno's ufmt supports no width flags, so the // padding is done by hand; unpadded numeric keys sort "1","10","2" and // the request list would lose its order past nine entries. idWidth = 12 ) // request is one ask. It is unexported because avl stores `any` and the // readable surface of this realm is its views and its page, not a struct // another realm would have to depend on. type request struct { ID int64 To address Amount int64 // ugnot Reason string By address // who filed it, which is usually not who receives it Asked int64 // chain height at filing Status string Judge address // who decided, zero while pending Decided int64 // chain height of the decision Note string // the denial reason; empty otherwise } var ( requests avl.Tree // padded id -> *request paid avl.Tree // recipient address -> int64 ugnot received in total approvers avl.Tree // approver address -> bool nextID int64 maxPerRequest int64 totalSent int64 sentCount int64 deniedCount int64 pendingN int64 ) func init() { reset() } // reset installs an empty faucet. Also called by the tests: realm globals // persist for a whole test binary and examples run after every Test, so a // pinned Render has to start from a known state. func reset() { requests = avl.Tree{} paid = avl.Tree{} approvers = avl.Tree{} approvers.Set(Owner.String(), true) nextID = 1 maxPerRequest = DefaultMaxPerRequest totalSent, sentCount, deniedCount, pendingN = 0, 0, 0, 0 } // Address is where the float sits: this realm's own package address. Fund the // faucet by sending ugnot to it, with a plain bank send or with [Fund]. func Address() address { return chain.PackageAddress(Path) } // Balance is what the faucet can actually pay out right now. func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom) } // NextID is the id the next [Request] will be given. // // Read it to build the [Approve] half of a two-message transaction, and pass // the recipient and amount to Approve so that a request landing in between // fails the transaction instead of being paid by it. func NextID() int64 { return nextID } // MaxPerRequest is the current cap on a single payout, in ugnot. func MaxPerRequest() int64 { return maxPerRequest } // TotalSent, SentCount, DeniedCount and Pending are the running tallies. func TotalSent() int64 { return totalSent } func SentCount() int64 { return sentCount } func DeniedCount() int64 { return deniedCount } func Pending() int64 { return pendingN } // Requests is how many requests have ever been filed. func Requests() int { return requests.Size() } // IsApprover reports whether addr may approve or deny. func IsApprover(addr string) bool { return approvers.Has(addr) } // ReceivedBy is everything this faucet has ever paid to addr. func ReceivedBy(addr string) int64 { if v := paid.Get(addr); v != nil { return v.(int64) } return 0 } // Status is the state of request id: "pending", "sent", "denied", or "" when // no such request exists. func Status(id int64) string { r := find(id) if r == nil { return "" } return r.Status } // Fund credits the ugnot sent with the call to the float. Coins sent straight // to [Address] land there too; they just do not emit an event. func Fund(cur realm) { from := unsafe.PreviousRealm().Address() amount := unsafe.OriginSend().AmountOf(Denom) if amount <= 0 { panic("faucet: send some " + Denom + " with the call") } chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10)) } // Request files an ask for amount ugnot to be paid to `to`, and returns its // id. Anyone may file, for anyone, which is the point: the account that needs // the coins is the one that cannot pay to ask for them. // // Filing costs the filer gas and nothing else, and moves no money. func Request(cur realm, to string, amount int64, reason string) int64 { by := unsafe.PreviousRealm().Address() dst := address(to) if !dst.IsValid() { panic("faucet: " + to + " is not a valid address") } if amount <= 0 { panic("faucet: amount must be a positive number of " + Denom) } if amount > maxPerRequest { panic("faucet: " + strconv.FormatInt(amount, 10) + Denom + " is over the per-request cap of " + strconv.FormatInt(maxPerRequest, 10) + Denom) } reason = strings.TrimSpace(reason) if reason == "" { panic("faucet: say what it is for") } if len(reason) > MaxReasonLen { panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes") } id := nextID nextID++ requests.Set(key(id), &request{ ID: id, To: dst, Amount: amount, Reason: reason, By: by, Asked: runtime.ChainHeight(), Status: StatusPending, }) pendingN++ chain.Emit("Request", "id", strconv.FormatInt(id, 10), "to", dst.String(), "amount", strconv.FormatInt(amount, 10), "by", by.String(), ) return id } // Approve pays request id out of the float. Approvers only. // // wantTo and wantAmount are not redundant: they are what makes it safe to put // Approve in the same transaction as the Request it settles. The id has to be // guessed from [NextID] before either message is signed, and this call refuses // when the request sitting at that id is not the one the caller described. func Approve(cur realm, id int64, wantTo string, wantAmount int64) { judge := unsafe.PreviousRealm().Address() mustApprove(judge) r := find(id) if r == nil { panic("faucet: no request " + strconv.FormatInt(id, 10)) } if r.Status != StatusPending { panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status) } if r.To.String() != wantTo || r.Amount != wantAmount { panic("faucet: request " + strconv.FormatInt(id, 10) + " pays " + strconv.FormatInt(r.Amount, 10) + Denom + " to " + r.To.String() + ", not " + strconv.FormatInt(wantAmount, 10) + Denom + " to " + wantTo + "; the id moved under you, nothing was paid") } if bal := Balance(); bal < r.Amount { panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom + ", request needs " + strconv.FormatInt(r.Amount, 10) + Denom) } banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins( cur.Address(), r.To, chain.NewCoins(chain.NewCoin(Denom, r.Amount))) r.Status = StatusSent r.Judge = judge r.Decided = runtime.ChainHeight() paid.Set(r.To.String(), ReceivedBy(r.To.String())+r.Amount) totalSent += r.Amount sentCount++ pendingN-- chain.Emit("Approve", "id", strconv.FormatInt(id, 10), "to", r.To.String(), "amount", strconv.FormatInt(r.Amount, 10), "by", judge.String(), ) } // Deny closes a request unpaid, with a reason that goes on the page. The // reason is the whole value of denying in public rather than ignoring it. func Deny(cur realm, id int64, why string) { judge := unsafe.PreviousRealm().Address() mustApprove(judge) r := find(id) if r == nil { panic("faucet: no request " + strconv.FormatInt(id, 10)) } if r.Status != StatusPending { panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status) } why = strings.TrimSpace(why) if why == "" { panic("faucet: say why") } if len(why) > MaxReasonLen { panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes") } r.Status = StatusDenied r.Judge = judge r.Decided = runtime.ChainHeight() r.Note = why deniedCount++ pendingN-- chain.Emit("Deny", "id", strconv.FormatInt(id, 10), "by", judge.String()) } // AddApprover lets addr approve and deny. Owner only. func AddApprover(cur realm, addr string) { mustOwn(unsafe.PreviousRealm().Address()) a := address(addr) if !a.IsValid() { panic("faucet: " + addr + " is not a valid address") } approvers.Set(a.String(), true) chain.Emit("AddApprover", "addr", a.String()) } // RemoveApprover revokes addr. Owner only, and the Owner cannot be removed: // a faucet with no approver is a faucet with a locked float. func RemoveApprover(cur realm, addr string) { mustOwn(unsafe.PreviousRealm().Address()) if addr == Owner.String() { panic("faucet: the owner is always an approver") } // avl's Remove returns (value, removed), unlike Get which returns one // value; the comma-ok is required here and forbidden there. if _, removed := approvers.Remove(addr); !removed { panic("faucet: " + addr + " is not an approver") } chain.Emit("RemoveApprover", "addr", addr) } // SetMaxPerRequest changes the per-request cap, in ugnot. Owner only. func SetMaxPerRequest(cur realm, amount int64) { mustOwn(unsafe.PreviousRealm().Address()) if amount <= 0 { panic("faucet: cap must be positive") } maxPerRequest = amount chain.Emit("SetMaxPerRequest", "amount", strconv.FormatInt(amount, 10)) } // Withdraw returns amount ugnot of the float to the Owner. Owner only. // // This is what makes funding the faucet reversible, and it is deliberately // not payable to an arbitrary address: an approver who wanted to move money // somewhere has to file a request for it like everyone else. func Withdraw(cur realm, amount int64) { mustOwn(unsafe.PreviousRealm().Address()) if amount <= 0 { panic("faucet: amount must be positive") } if bal := Balance(); amount > bal { panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom) } banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins( cur.Address(), Owner, chain.NewCoins(chain.NewCoin(Denom, amount))) chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10)) } func mustOwn(caller address) { if caller != Owner { panic("faucet: owner only") } } func mustApprove(caller address) { if !approvers.Has(caller.String()) { panic("faucet: " + caller.String() + " is not an approver") } } func find(id int64) *request { v := requests.Get(key(id)) if v == nil { return nil } return v.(*request) } // key pads an id to idWidth digits so the avl tree iterates in filing order. func key(id int64) string { s := strconv.FormatInt(id, 10) for len(s) < idWidth { s = "0" + s } return s }
  6. #6faucet_test.gno
  7. #7package faucet import ( "chain" "chain/banker" "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const ( zoe = address("g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz") // a friend with nothing kim = address("g1jvh5ukk07dvd57fxefcp5aa29xaydxmxs7myyp") // a second approver carl = address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // nobody in particular ) func balanceOf(a address) int64 { return banker.NewReadonlyBanker().GetCoins(a).AmountOf(Denom) } // fund puts ugnot in the float the way a bank send to the realm address does, // with no call involved. Approve spends this, so a test that funds more than // it pays leaves a balance behind and ExampleRender sees it. func fund(n int64) { testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin(Denom, n))) } func TestFreshFaucetIsEmptyAndOwned(t *testing.T) { reset() uassert.True(t, Owner.IsValid(), "the owner is a real address") uassert.True(t, Address().IsValid(), "the float address") uassert.True(t, zoe.IsValid()) uassert.True(t, kim.IsValid()) uassert.True(t, carl.IsValid()) uassert.Equal(t, 0, Requests(), "nothing is seeded") uassert.Equal(t, int64(1), NextID()) uassert.Equal(t, int64(0), TotalSent()) uassert.Equal(t, int64(DefaultMaxPerRequest), MaxPerRequest()) uassert.True(t, IsApprover(Owner.String()), "the owner approves by default") uassert.False(t, IsApprover(carl.String())) uassert.Equal(t, "", Status(1), "no such request") } // The whole point, end to end: carl asks on zoe's behalf, the owner releases // it, and zoe (who could never have paid the gas to ask) has coins. func TestRequestForAFriendThenApprove(cur realm, t *testing.T) { reset() urequire.Equal(t, int64(0), Balance(), "start from an empty float") fund(300_000_000) testing.SetRealm(testing.NewUserRealm(carl)) id := Request(cross(cur), zoe.String(), 100_000_000, "no gas, needs to try the chain") uassert.Equal(t, int64(1), id) uassert.Equal(t, StatusPending, Status(id)) uassert.Equal(t, int64(1), Pending()) uassert.Equal(t, int64(0), balanceOf(zoe), "a request moves nothing") uassert.Equal(t, int64(2), NextID(), "and the next id is now readable") // The filer is not an approver just because they filed. testing.SetRealm(testing.NewUserRealm(carl)) uassert.AbortsContains(t, cur, "is not an approver", func() { Approve(cross(cur), id, zoe.String(), 100_000_000) }) testing.SetRealm(testing.NewUserRealm(Owner)) Approve(cross(cur), id, zoe.String(), 100_000_000) uassert.Equal(t, int64(100_000_000), balanceOf(zoe), "the coins landed") uassert.Equal(t, int64(200_000_000), Balance(), "out of the float, not the approver") uassert.Equal(t, StatusSent, Status(id)) uassert.Equal(t, int64(100_000_000), TotalSent()) uassert.Equal(t, int64(1), SentCount()) uassert.Equal(t, int64(0), Pending()) uassert.Equal(t, int64(100_000_000), ReceivedBy(zoe.String())) // A decision happens once. testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "already sent", func() { Approve(cross(cur), id, zoe.String(), 100_000_000) }) // Put the float back so ExampleRender starts from zero. testing.SetRealm(testing.NewUserRealm(Owner)) Withdraw(cross(cur), Balance()) uassert.Equal(t, int64(0), Balance()) } // The guard that makes it safe to sign Request and Approve in the same // transaction before either has run. The approval names an id it had to guess // from NextID; if a different request took that id, the amounts and the // recipient no longer agree and nothing is paid. func TestApproveRefusesWhenTheIDMovedUnderYou(cur realm, t *testing.T) { reset() fund(500_000_000) // What the caller read: the next id will be 1. urequire.Equal(t, int64(1), NextID()) // What actually landed at id 1: somebody else's ask, first. testing.SetRealm(testing.NewUserRealm(kim)) Request(cross(cur), kim.String(), 50_000_000, "me first") testing.SetRealm(testing.NewUserRealm(carl)) Request(cross(cur), zoe.String(), 100_000_000, "for zoe") testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "the id moved under you, nothing was paid", func() { Approve(cross(cur), 1, zoe.String(), 100_000_000) }) uassert.Equal(t, int64(0), balanceOf(zoe), "nobody was paid") uassert.Equal(t, StatusPending, Status(1), "and nothing was decided") // The same guard catches a wrong amount at the right id. testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "the id moved under you, nothing was paid", func() { Approve(cross(cur), 2, zoe.String(), 999_000_000) }) testing.SetRealm(testing.NewUserRealm(Owner)) Approve(cross(cur), 2, zoe.String(), 100_000_000) uassert.Equal(t, int64(100_000_000), balanceOf(zoe)) testing.SetRealm(testing.NewUserRealm(Owner)) Deny(cross(cur), 1, "already funded elsewhere") uassert.Equal(t, StatusDenied, Status(1)) uassert.Equal(t, int64(1), DeniedCount()) uassert.Equal(t, int64(0), Pending()) testing.SetRealm(testing.NewUserRealm(Owner)) Withdraw(cross(cur), Balance()) } func TestRequestRefusesBadInput(cur realm, t *testing.T) { reset() tests := []struct { name string to string amount int64 reason string want string }{ {"not an address", "nope", 1_000_000, "hi", "is not a valid address"}, {"zero", zoe.String(), 0, "hi", "must be a positive number"}, {"negative", zoe.String(), -1, "hi", "must be a positive number"}, {"over the cap", zoe.String(), DefaultMaxPerRequest + 1, "hi", "is over the per-request cap"}, {"no reason", zoe.String(), 1_000_000, " ", "say what it is for"}, } for _, tt := range tests { testing.SetRealm(testing.NewUserRealm(carl)) uassert.AbortsContains(t, cur, tt.want, func() { Request(cross(cur), tt.to, tt.amount, tt.reason) }, tt.name) } uassert.Equal(t, 0, Requests(), "none of them were filed") uassert.Equal(t, int64(1), NextID(), "and the id did not move") } func TestApproveRefusesWhatTheFloatCannotCover(cur realm, t *testing.T) { reset() fund(10_000_000) testing.SetRealm(testing.NewUserRealm(carl)) id := Request(cross(cur), zoe.String(), 100_000_000, "more than is there") testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "float is 10000000ugnot", func() { Approve(cross(cur), id, zoe.String(), 100_000_000) }) uassert.Equal(t, StatusPending, Status(id), "still open, the money simply is not there") testing.SetRealm(testing.NewUserRealm(Owner)) Withdraw(cross(cur), Balance()) } func TestOnlyTheOwnerChangesWhoApproves(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(carl)) uassert.AbortsContains(t, cur, "owner only", func() { AddApprover(cross(cur), carl.String()) }) testing.SetRealm(testing.NewUserRealm(Owner)) AddApprover(cross(cur), kim.String()) uassert.True(t, IsApprover(kim.String())) // A second approver can decide, but cannot widen the roster. fund(200_000_000) testing.SetRealm(testing.NewUserRealm(carl)) id := Request(cross(cur), zoe.String(), 50_000_000, "kim vouches") testing.SetRealm(testing.NewUserRealm(kim)) Approve(cross(cur), id, zoe.String(), 50_000_000) uassert.Equal(t, StatusSent, Status(id)) testing.SetRealm(testing.NewUserRealm(kim)) uassert.AbortsContains(t, cur, "owner only", func() { AddApprover(cross(cur), carl.String()) }) // The owner is the one approver that cannot be removed: a faucet with no // approver is a faucet with a locked float. testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "the owner is always an approver", func() { RemoveApprover(cross(cur), Owner.String()) }) testing.SetRealm(testing.NewUserRealm(Owner)) RemoveApprover(cross(cur), kim.String()) uassert.False(t, IsApprover(kim.String())) testing.SetRealm(testing.NewUserRealm(Owner)) Withdraw(cross(cur), Balance()) } func TestFundAndWithdrawAreOwnerReversible(cur realm, t *testing.T) { reset() fund(400_000_000) testing.SetOriginSend(chain.NewCoins(chain.NewCoin(Denom, 400_000_000))) testing.SetRealm(testing.NewUserRealm(kim)) Fund(cross(cur)) uassert.Equal(t, int64(400_000_000), Balance()) testing.SetRealm(testing.NewUserRealm(kim)) uassert.AbortsContains(t, cur, "owner only", func() { Withdraw(cross(cur), 1) }) testing.SetRealm(testing.NewUserRealm(Owner)) uassert.AbortsContains(t, cur, "float is 400000000ugnot", func() { Withdraw(cross(cur), 400_000_001) }) before := balanceOf(Owner) testing.SetRealm(testing.NewUserRealm(Owner)) Withdraw(cross(cur), 400_000_000) uassert.Equal(t, int64(0), Balance()) uassert.Equal(t, before+400_000_000, balanceOf(Owner)) } func TestSetMaxPerRequest(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(carl)) uassert.AbortsContains(t, cur, "owner only", func() { SetMaxPerRequest(cross(cur), 1) }) testing.SetRealm(testing.NewUserRealm(Owner)) SetMaxPerRequest(cross(cur), 1_000_000_000) uassert.Equal(t, int64(1_000_000_000), MaxPerRequest()) testing.SetRealm(testing.NewUserRealm(carl)) id := Request(cross(cur), zoe.String(), 900_000_000, "over the old cap, under the new one") uassert.Equal(t, StatusPending, Status(id)) } // A reason is arbitrary text from an arbitrary account, and this realm's path // is permanent, so the escaping has to be right before it ships. Assert the // dangerous SEQUENCES are dead on the page, not that some particular escape // was chosen. func TestRenderEscapesTheReason(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(carl)) id := Request(cross(cur), zoe.String(), 1_000_000, "[click](https://evil.example) ![b](https://evil.example/p.png) <gno-columns>") for _, page := range []string{Render(""), Render("req/" + "1")} { uassert.False(t, contains(page, "](https://evil.example"), "no live link") uassert.False(t, contains(page, "![b]"), "no image beacon") uassert.False(t, contains(page, "<gno-columns>"), "no gnoweb chrome") uassert.True(t, contains(page, "click"), "the readable text survives") } uassert.Equal(t, StatusPending, Status(id)) } func contains(haystack, needle string) bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false } func TestGnotFormatting(t *testing.T) { tests := []struct { in int64 want string }{ {0, "0"}, {1, "0.000001"}, {1_500_000, "1.5"}, {100_000_000, "100"}, {5_000_000_000, "5000"}, {-2_500_000, "-2.5"}, } for _, tt := range tests { uassert.Equal(t, tt.want, gnot(tt.in)) } } func TestReqPath(t *testing.T) { tests := []struct { in string id int64 want bool }{ {"", 0, false}, {"req/1", 1, true}, {"req/42", 42, true}, {"req/0", 0, false}, {"req/x", 0, false}, {"req/", 0, false}, {"other", 0, false}, } for _, tt := range tests { id, ok := reqPath(tt.in) uassert.Equal(t, tt.want, ok, tt.in) uassert.Equal(t, tt.id, id, tt.in) } }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/faucet/v0" gno = "0.9"
  10. #10render.gno
  11. #11package faucet import ( "strconv" "strings" "gno.land/p/nt/markdown/sanitize/v0" ) // Render shows the float, the open asks and everything already decided. // // Two paths: "" is the whole board, "req/<id>" is one request. // // Every string on this page that a caller typed goes through sanitize before // it is concatenated. A reason is arbitrary text from an arbitrary account and // Render output is markdown that gnoweb parses, so an unescaped one can plant // a link, an image beacon or gnoweb chrome on a page the realm is signing for. // This realm's path is permanent, so that bug could only ever be fixed at a // new path. func Render(path string) string { path = strings.TrimPrefix(path, "/") if id, ok := reqPath(path); ok { return renderOne(id) } return renderBoard() } func renderBoard() string { var b strings.Builder b.WriteString("# GNOT faucet\n\n") b.WriteString(intro) b.WriteString("\n\n") b.WriteString("| | |\n| --- | ---: |\n") b.WriteString("| Float available | **" + gnot(Balance()) + " GNOT** |\n") b.WriteString("| Paid out | " + gnot(totalSent) + " GNOT over " + strconv.FormatInt(sentCount, 10) + " request(s) |\n") b.WriteString("| Open requests | " + strconv.FormatInt(pendingN, 10) + " |\n") b.WriteString("| Refused | " + strconv.FormatInt(deniedCount, 10) + " |\n") b.WriteString("| Cap per request | " + gnot(maxPerRequest) + " GNOT |\n") b.WriteString("| Float address | `" + Address().String() + "` |\n\n") b.WriteString("## Open\n\n") b.WriteString(table(StatusPending)) b.WriteString("\n## Decided\n\n") b.WriteString(table("")) b.WriteString("\n## Approvers\n\n") approvers.Iterate("", "", func(k string, _ any) bool { b.WriteString("- `" + k + "`\n") return false }) b.WriteString("\n" + howto + "\n") return b.String() } // table lists requests in filing order, keeping only those in `want`, or // everything already decided when want is empty. func table(want string) string { var rows strings.Builder n := 0 requests.Iterate("", "", func(_ string, v any) bool { r := v.(*request) if want == "" && r.Status == StatusPending { return false } if want != "" && r.Status != want { return false } n++ rows.WriteString("| [" + strconv.FormatInt(r.ID, 10) + "](" + Link + ":req/" + strconv.FormatInt(r.ID, 10) + ") | `" + r.To.String() + "` | " + gnot(r.Amount) + " | " + sanitize.TableCell(excerpt(r.Reason, 60)) + " | " + r.Status + " |\n") return false }) if n == 0 { return "_Nothing here yet._\n" } return "| # | To | GNOT | Why | State |\n| ---: | --- | ---: | --- | --- |\n" + rows.String() } func renderOne(id int64) string { r := find(id) if r == nil { return "# Request " + strconv.FormatInt(id, 10) + "\n\n_No such request._\n" } var b strings.Builder b.WriteString("# Request " + strconv.FormatInt(r.ID, 10) + "\n\n") b.WriteString("| | |\n| --- | --- |\n") b.WriteString("| To | `" + r.To.String() + "` |\n") b.WriteString("| Amount | " + gnot(r.Amount) + " GNOT |\n") b.WriteString("| Filed by | `" + r.By.String() + "` |\n") b.WriteString("| Filed at height | " + strconv.FormatInt(r.Asked, 10) + " |\n") b.WriteString("| State | " + r.Status + " |\n") if r.Status != StatusPending { b.WriteString("| Decided by | `" + r.Judge.String() + "` |\n") b.WriteString("| Decided at height | " + strconv.FormatInt(r.Decided, 10) + " |\n") } b.WriteString("\n**Why:** " + sanitize.InlineText(r.Reason) + "\n") if r.Note != "" { b.WriteString("\n**Refused because:** " + sanitize.InlineText(r.Note) + "\n") } b.WriteString("\n[Back to the faucet](" + Link + ")\n") return b.String() } // reqPath parses "req/<id>". func reqPath(path string) (int64, bool) { rest, ok := strings.CutPrefix(path, "req/") if !ok { return 0, false } id, err := strconv.ParseInt(rest, 10, 64) if err != nil || id <= 0 { return 0, false } return id, true } // excerpt cuts to width RUNES, never bytes: slicing a multi-byte character in // half puts invalid UTF-8 on the page, and one emoji in a reason is enough. // It cuts before escaping, because escaping inserts backslashes and cutting an // already-escaped string can strand a lone one that escapes the chrome after // it. func excerpt(s string, width int) string { r := []rune(s) if len(r) <= width+1 { return s } return string(r[:width]) + "…" } // gnot renders ugnot as GNOT with the trailing zeros of the fraction trimmed, // so 100000000 reads as 100 and 1500000 as 1.5. func gnot(ugnot int64) string { neg := "" if ugnot < 0 { neg, ugnot = "-", -ugnot } whole := strconv.FormatInt(ugnot/1_000_000, 10) frac := strconv.FormatInt(ugnot%1_000_000, 10) if frac == "0" { return neg + whole } for len(frac) < 6 { frac = "0" + frac } return neg + whole + "." + strings.TrimRight(frac, "0") } const ( intro = "Somebody you trust has no GNOT and cannot use the public faucet. File a\n" + "request on their behalf, an approver releases it, and both halves stay on\n" + "this page with a reason attached. The faucet only ever spends what has been\n" + "sent to its own address." howto = "## How\n\n" + "`Request(to, amount, reason)` is open to anyone and moves no money.\n" + "`Approve(id, to, amount)` pays it, and only an approver can call it.\n\n" + "Send both in **one transaction**: a tm2 transaction stops at the first\n" + "message that fails and writes none of their state, so the ask and its\n" + "answer land together or not at all. Read `NextID` to address the approval,\n" + "and pass the recipient and the amount into it as well: if another request\n" + "takes that id first, the mismatch fails the transaction instead of paying\n" + "the wrong account." )
  12. #12render_example_test.gno
  13. #13package faucet // ExampleRender pins the faucet as it ships: no requests, an empty float, one // approver. That IS the deployed state, since nothing is seeded here. // // reset() first: realm globals persist for the whole test binary and examples // run after every Test. The float is NOT a realm global and reset() cannot // clear it, so every test above withdraws what it funded; a test that leaves // coins behind shows up as a diff here, which is the point. func ExampleRender() { reset() print(Render("")) // Output: // # GNOT faucet // // Somebody you trust has no GNOT and cannot use the public faucet. File a // request on their behalf, an approver releases it, and both halves stay on // this page with a reason attached. The faucet only ever spends what has been // sent to its own address. // // | | | // | --- | ---: | // | Float available | **0 GNOT** | // | Paid out | 0 GNOT over 0 request(s) | // | Open requests | 0 | // | Refused | 0 | // | Cap per request | 200 GNOT | // | Float address | `g18jy5sp2hx5n8kkwvvs3l908ynyqdxgfh44ay88` | // // ## Open // // _Nothing here yet._ // // ## Decided // // _Nothing here yet._ // // ## Approvers // // - `g1manfred47kzduec920z88wfr64ylksmdcedlf5` // // ## How // // `Request(to, amount, reason)` is open to anyone and moves no money. // `Approve(id, to, amount)` pays it, and only an approver can call it. // // Send both in **one transaction**: a tm2 transaction stops at the first // message that fails and writes none of their state, so the ask and its // answer land together or not at all. Read `NextID` to address the approval, // and pass the recipient and the amount into it as well: if another request // takes that id first, the mismatch fails the transaction instead of paying // the wrong account. } // ExampleRenderUnknownRequest pins the per-request path on the one state it // can reach deterministically from a reset faucet. A request page proper needs // a crossing call to exist, and an Example func cannot take `cur realm`, so // the filled-in version is asserted in TestRenderEscapesTheReason instead. func ExampleRenderUnknownRequest() { reset() print(Render("req/7")) // Output: // # Request 7 // // _No such request._ }
  14. #14/gno.MemPackageType
  15. #15 MPUserAll

Result log

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

← Back to block 245,194