diff --git a/examples/gno.land/p/demo/auction/auction.gno b/examples/gno.land/p/demo/auction/auction.gno new file mode 100644 index 00000000000..27238191987 --- /dev/null +++ b/examples/gno.land/p/demo/auction/auction.gno @@ -0,0 +1,94 @@ +package auction + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/ownable" + "gno.land/p/demo/ufmt" +) + +// Auction struct +type Auction struct { + Owner ownable.Ownable // Embeds Ownable to handle ownership + Title string + Description string + Begin time.Time + End time.Time + StartingBid std.Coin + Bids []*Bid + State string // "upcoming", "ongoing", or "closed" +} + +// Bid struct +type Bid struct { + Bidder std.Address + Amount std.Coin +} + +var ( + auctionList []*Auction + currentTime time.Time +) + +// NewAuction creates a new auction +func NewAuction( + title string, + owner std.Address, + description string, + begin time.Time, + end time.Time, + startingBid std.Coin, +) *Auction { + return &Auction{ + Owner: *ownable.NewWithAddress(owner), // Initialize Ownable with the owner + Title: title, + Description: description, + Begin: begin, + End: end, + StartingBid: startingBid, + Bids: []*Bid{}, + State: "upcoming", + } +} + +// AddBid adds a new bid to the auction +func (a *Auction) AddBid(bidder std.Address, amount std.Coin) error { + if time.Now().Before(a.Begin) { + return ufmt.Errorf("auction: AddBid: auction has not started yet") + } + if time.Now().After(a.End) { + return ufmt.Errorf("auction: AddBid: auction has already ended") + } + if !amount.IsGTE(a.StartingBid) { + return ufmt.Errorf("auction: AddBid: bid amount must be higher than the current highest bid") + } + bid := &Bid{Bidder: bidder, Amount: amount} + a.Bids = append(a.Bids, bid) + a.StartingBid = amount + std.Emit("BidPlaced", "auction", a.Title, "bidder", bidder.String(), "amount", amount.String()) + return nil +} + +// EndAuction ends the auction +func (a *Auction) EndAuction() error { + if err := a.Owner.CallerIsOwner(); err != nil { + return err + } + if time.Now().Before(a.End) { + return ufmt.Errorf("auction: EndAuction: auction cannot end before the end time") + } + if a.State == "closed" { + return ufmt.Errorf("auction: EndAuction: auction is already closed") + } + if len(a.Bids) == 0 { + std.Emit("AuctionEndedNoBids", "auction", a.Title) + return ufmt.Errorf("auction: EndAuction: auction ended with no bids") + } + a.State = "closed" + highestBid := a.StartingBid + winner := a.Bids[len(a.Bids)-1].Bidder + std.Emit("AuctionEnded", "winner", winner.String(), "amount", highestBid.String()) + return nil +} diff --git a/examples/gno.land/p/demo/auction/auction_test.gno b/examples/gno.land/p/demo/auction/auction_test.gno new file mode 100644 index 00000000000..6d8e0ca1f35 --- /dev/null +++ b/examples/gno.land/p/demo/auction/auction_test.gno @@ -0,0 +1,172 @@ +package auction + +import ( + "std" + "testing" + "time" + + "gno.land/p/demo/ownable" + "gno.land/p/demo/testutils" +) + +func setCurrentTime(t time.Time) { + currentTime = t +} + +func resetCurrentTime() { + currentTime = time.Time{} +} + +func TestNewAuction(t *testing.T) { + owner := std.Address("owner") + begin := time.Now().Add(1 * time.Hour) + end := time.Now().Add(24 * time.Hour) + startingBid := std.NewCoin("ugnot", 100) + + auction := NewAuction("Test Auction", owner, "This is a test auction", begin, end, startingBid) + + if auction.Title != "Test Auction" { + t.Fatalf("expected auction title to be 'Test Auction', got '%s'", auction.Title) + } + if auction.Owner.Owner() != owner { // Note the change here to access owner + t.Fatalf("expected auction owner to be '%s', got '%s'", owner, auction.Owner.Owner()) + } + if auction.Description != "This is a test auction" { + t.Fatalf("expected auction description to be 'This is a test auction', got '%s'", auction.Description) + } + if auction.Begin != begin { + t.Fatalf("expected auction begin time to be '%s', got '%s'", begin, auction.Begin) + } + if auction.End != end { + t.Fatalf("expected auction end time to be '%s', got '%s'", end, auction.End) + } + if !auction.StartingBid.IsEqual(startingBid) { + t.Fatalf("expected auction starting bid to be '%s', got '%s'", startingBid.String(), auction.StartingBid.String()) + } + if auction.State != "upcoming" { + t.Fatalf("expected auction state to be 'upcoming', got '%s'", auction.State) + } +} + +func TestAddBid(t *testing.T) { + owner := std.Address("owner") + bidder1 := std.Address("bidder1") + bidder2 := std.Address("bidder2") + begin := time.Now().Add(1 * time.Hour) + end := time.Now().Add(24 * time.Hour) + startingBid := std.NewCoin("ugnot", 100) + + auction := NewAuction("Test Auction", owner, "This is a test auction", begin, end, startingBid) + + // Test before auction starts + setCurrentTime(time.Now()) + err := auction.AddBid(bidder1, std.NewCoin("ugnot", 200)) + if err == nil || err.Error() != "auction: AddBid: auction has not started yet" { + t.Fatalf("expected error 'auction has not started yet', got '%v'", err) + } + resetCurrentTime() + + // Test successful bid + setCurrentTime(begin.Add(1 * time.Second)) + err = auction.AddBid(bidder1, std.NewCoin("ugnot", 200)) + resetCurrentTime() + + if err != nil { + t.Fatalf("expected no error, got '%v'", err) + } + if !auction.StartingBid.IsEqual(std.NewCoin("ugnot", 200)) { + t.Fatalf("expected auction starting bid to be '200ugnot', got '%s'", auction.StartingBid.String()) + } + if len(auction.Bids) != 1 { + t.Fatalf("expected number of bids to be '1', got '%d'", len(auction.Bids)) + } + if auction.Bids[0].Bidder != bidder1 { + t.Fatalf("expected bidder to be 'bidder1', got '%s'", auction.Bids[0].Bidder) + } + + // Test higher bid + setCurrentTime(begin.Add(2 * time.Second)) + err = auction.AddBid(bidder2, std.NewCoin("ugnot", 300)) + resetCurrentTime() + + if err != nil { + t.Fatalf("expected no error, got '%v'", err) + } + if !auction.StartingBid.IsEqual(std.NewCoin("ugnot", 300)) { + t.Fatalf("expected auction starting bid to be '300ugnot', got '%s'", auction.StartingBid.String()) + } + if len(auction.Bids) != 2 { + t.Fatalf("expected number of bids to be '2', got '%d'", len(auction.Bids)) + } + if auction.Bids[1].Bidder != bidder2 { + t.Fatalf("expected bidder to be 'bidder2', got '%s'", auction.Bids[1].Bidder) + } + + // Test bid lower than current price + setCurrentTime(begin.Add(3 * time.Second)) + err = auction.AddBid(bidder1, std.NewCoin("ugnot", 250)) + resetCurrentTime() + if err == nil || err.Error() != "auction: AddBid: bid amount must be higher than the current highest bid" { + t.Fatalf("expected error 'bid amount must be higher than the current highest bid', got '%v'", err) + } +} + +func TestEndAuction(t *testing.T) { + owner := testutils.TestAddress("owner") + bidder := testutils.TestAddress("bidder") + begin := time.Now().Add(1 * time.Hour) + end := time.Now().Add(24 * time.Hour) + startingBid := std.NewCoin("ugnot", 100) + + auction := NewAuction("Test Auction", owner, "This is a test auction", begin, end, startingBid) + owner.AssertCallerIsOwner() + // Test ending auction before it starts + setCurrentTime(begin.Add(-1 * time.Hour)) + + err := auction.EndAuction() + if err == nil || err.Error() != "auction: EndAuction: auction cannot end before the end time" { + t.Fatalf("expected error 'auction cannot end before the end time', got '%v'", err) + } + resetCurrentTime() + + // Test ending auction with no bids + setCurrentTime(end.Add(1 * time.Second)) + err = auction.EndAuction() + if err == nil || err.Error() != "auction: EndAuction: auction ended with no bids" { + t.Fatalf("expected error 'auction ended with no bids', got '%v'", err) + } + resetCurrentTime() + + // Place a bid and end auction + setCurrentTime(begin.Add(1 * time.Second)) + auction.AddBid(bidder, std.NewCoin("ugnot", 200)) + setCurrentTime(end.Add(1 * time.Second)) + err = auction.EndAuction() + resetCurrentTime() + + if err != nil { + t.Fatalf("expected no error, got '%v'", err) + } + if auction.State != "closed" { + t.Fatalf("expected auction state to be 'closed', got '%s'", auction.State) + } + if len(auction.Bids) == 0 { + t.Fatalf("expected at least one bid to be present") + } + if auction.Bids[len(auction.Bids)-1].Bidder != bidder { + t.Fatalf("expected winner to be 'bidder', got '%s'", auction.Bids[len(auction.Bids)-1].Bidder) + } +} + +// SetCurrentTime sets the current time for testing purposes +func SetCurrentTime(t time.Time) { + currentTime = t +} + +// now returns the current time, allowing for mocking in tests +func now() time.Time { + if !currentTime.IsZero() { + return currentTime + } + return time.Now() +} diff --git a/examples/gno.land/p/demo/auction/gno.mod b/examples/gno.land/p/demo/auction/gno.mod new file mode 100644 index 00000000000..b0063ea8f9a --- /dev/null +++ b/examples/gno.land/p/demo/auction/gno.mod @@ -0,0 +1,6 @@ +module gno.land/p/demo/auction + +require ( + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest +) diff --git a/examples/gno.land/r/demo/auction/auction.gno b/examples/gno.land/r/demo/auction/auction.gno new file mode 100644 index 00000000000..aefe36be126 --- /dev/null +++ b/examples/gno.land/r/demo/auction/auction.gno @@ -0,0 +1,163 @@ +package auction + +import ( + "bytes" + "std" + "strconv" + "time" + + auctionp "gno.land/p/demo/auction" + "gno.land/p/demo/mux" +) + +var ( + auctionList []*auctionp.Auction + currentTime time.Time + // router *mux.Router +) + +var router = mux.NewRouter() + +func init() { + router.HandleFunc("", renderHomepage) + router.HandleFunc("create/", renderCreateAuction) + router.HandleFunc("upcoming/", renderUpcomingAuctions) + router.HandleFunc("ongoing/", renderOngoingAuctions) + router.HandleFunc("/auction/{id}", renderAuctionDetails) +} + +// CreateAuction handles the creation of a new auction +func CreateAuction(title, description string, begin, end int64, price std.Coin) string { + owner := std.GetOrigCaller() + beginTime := time.Unix(begin, 0) + endTime := time.Unix(end, 0) + auction := auctionp.NewAuction(title, owner, description, beginTime, endTime, price) + auctionList = append(auctionList, auction) + return "Auction created successfully" +} + +// PlaceBid handles placing a bid on an auction +func PlaceBid(id int, amount std.Coin) string { + caller := std.GetOrigCaller() + if id >= len(auctionList) || id < 0 { + panic("Invalid auction ID") + } + auction := auctionList[id] + if err := auction.AddBid(caller, amount); err != nil { + panic(err.Error()) + } + return "Bid placed successfully" +} + +// EndAuction handles ending an auction +func EndAuction(id int) string { + caller := std.GetOrigCaller() + if id >= len(auctionList) || id < 0 { + panic("Invalid auction ID") + } + auction := auctionList[id] + if err := auction.EndAuction(caller); err != nil { + panic(err.Error()) + } + return "Auction ended successfully" +} + +// Render renders the requested page +func Render(path string) string { + return router.Render(path) +} + +// renderHomepage renders the homepage with links to different sections +func renderHomepage(res *mux.ResponseWriter, req *mux.Request) { + var b bytes.Buffer + b.WriteString("

Auction Home

\n\n") + b.WriteString("

Create New Auction

\n\n") + b.WriteString("

Upcoming Auctions

\n\n") + b.WriteString("

Ongoing Auctions

\n\n") + res.Write(b.String()) +} + +// renderCreateAuction renders the create auction page +func renderCreateAuction(res *mux.ResponseWriter, req *mux.Request) { + var b bytes.Buffer + b.WriteString("") + + b.WriteString("#Create New Auction\n") + b.WriteString("
\n") + b.WriteString("Title:
\n") + b.WriteString("Description:
\n") + b.WriteString("Begin:
\n") + b.WriteString("End:
\n") + b.WriteString("Starting Price:
\n") + b.WriteString("\n") + b.WriteString("
\n") + b.WriteString("") + res.Write(b.String()) +} + +// renderUpcomingAuctions renders the upcoming auctions page +func renderUpcomingAuctions(res *mux.ResponseWriter, req *mux.Request) { + var b bytes.Buffer + b.WriteString("

Upcoming Auctions

\n") + + for i, auc := range auctionList { + if auc.State == "upcoming" { + b.WriteString("\n\n") + b.WriteString("## " + auc.Title) + b.WriteString("\n\n") + b.WriteString("### Owner: " + auc.Owner.String() + "\n") + b.WriteString("### Description: " + auc.Description + "\n\n") + b.WriteString("This auction starts on: " + auc.Begin.String() + " and ends on: " + auc.End.String() + "\n\n") + b.WriteString("### Starting Price: " + strconv.FormatUint(auc.Price, 10) + "\n") + b.WriteString("[View Auction](/auction/" + strconv.Itoa(i) + ")\n") + } + } + res.Write(b.String()) +} + +// renderOngoingAuctions renders the ongoing auctions page +func renderOngoingAuctions(res *mux.ResponseWriter, req *mux.Request) { + var b bytes.Buffer + b.WriteString("

Ongoing Auctions

\n") + + for i, auc := range auctionList { + if auc.State == "ongoing" { + b.WriteString("##" + auc.Title + "\n") + b.WriteString("### Owner: " + auc.Owner.String() + "\n") + b.WriteString("### Description: " + auc.Description + "\n\n") + b.WriteString("This auction started on: " + auc.Begin.String() + " and ends on: " + auc.End.String() + "\n\n") + b.WriteString("### Current Price: " + strconv.FormatUint(auc.Price, 10) + "\n") + b.WriteString("[View Auction](/auction/" + strconv.Itoa(i) + ")\n") + } + } + res.Write(b.String()) +} + +// renderAuctionDetails renders the details of a specific auction +func renderAuctionDetails(res *mux.ResponseWriter, req *mux.Request) { + idStr := req.GetVar("id") + id, err := strconv.Atoi(idStr) + if err != nil || id >= len(auctionList) || id < 0 { + res.Write("Invalid auction ID") + return + } + auc := auctionList[id] + + var b bytes.Buffer + b.WriteString("

Auction Details

\n") + b.WriteString("## " + auc.Title + "\n") + b.WriteString("### Owner: " + auc.Owner.String() + "\n") + b.WriteString("### Description: " + auc.Description + "\n\n") + b.WriteString("This auction starts on: " + auc.Begin.String() + " and ends on: " + auc.End.String() + "\n\n") + b.WriteString("### Current Price: " + strconv.FormatUint(auc.Price, 10) + "\n") + + if auc.State == "ongoing" { + b.WriteString("
\n") + b.WriteString("Amount:
\n") + b.WriteString("\n") + b.WriteString("\n") + b.WriteString("
\n") + } + + res.Write(b.String()) +} diff --git a/examples/gno.land/r/demo/auction/auction_test.gno b/examples/gno.land/r/demo/auction/auction_test.gno new file mode 100644 index 00000000000..dc37334a9f8 --- /dev/null +++ b/examples/gno.land/r/demo/auction/auction_test.gno @@ -0,0 +1,92 @@ +package auction + +import ( + "std" + "strings" + "testing" + "time" +) + +func setCurrentTime(t time.Time) { + currentTime = t +} + +func resetCurrentTime() { + currentTime = time.Time{} +} + +func TestAuction(t *testing.T) { + // Initialize the router and handlers + + // Simulate the admin creating an auction + adminAddr := std.Address("admin") + std.TestSetOrigCaller(adminAddr) + + // Create an auction that starts almost immediately + begin := time.Now().Add(1 * time.Second).Unix() // Auction begins in 1 second + end := time.Now().Add(24 * time.Hour).Unix() // Auction ends in 24 hours + CreateAuction("Test Auction", "A simple test auction", begin, end, 100) + + // Check if auction is in the upcoming section + std.TestSkipHeights(1) // Skip 1 block to simulate time passage + updateAuctionStates() + upcomingPage := Render("upcoming") + if !strings.Contains(upcomingPage, "Test Auction") { + t.Errorf("Auction should be listed in upcoming auctions") + } + + // Simulate time passing to start the auction + std.TestSkipHeights(360) // Skip 360 blocks (1800 seconds or 30 minutes) + updateAuctionStates() + + // Check if auction is in the ongoing section + ongoingPage := Render("ongoing") + if !strings.Contains(ongoingPage, "Test Auction") { + t.Errorf("Auction should be listed in ongoing auctions") + } + + // Simulate users placing bids + user1 := std.Address("user1") + user2 := std.Address("user2") + + // Set the caller to user1 and place a bid + std.TestSetOrigCaller(user1) + PlaceBid(0, 200) + + // Set the caller to user2 and place a bid + std.TestSetOrigCaller(user2) + PlaceBid(0, 300) + + // Check the details of the auction to verify bids + auctionDetails := Render("auction/0") + if !strings.Contains(auctionDetails, "300") { + t.Errorf("Highest bid should be 300") + } + + // End the auction + std.TestSetOrigCaller(adminAddr) + EndAuction(0) + + // Check if auction is in the closed state + std.TestSkipHeights(8640) // Skip 8640 blocks (43200 seconds or 12 hours) + updateAuctionStates() + auctionDetails = Render("auction/0") + if !strings.Contains(auctionDetails, "Auction ended") { + t.Errorf("Auction should be ended") + } + + resetCurrentTime() +} + +// Update the auction states based on the current time + +func updateAuctionStates() { + now := time.Now() + for _, auc := range auctionList { + if auc.State == "upcoming" && now.After(auc.Begin) { + auc.State = "ongoing" + } else if auc.State == "ongoing" && now.After(auc.End) { + auc.State = "closed" + } + } +} diff --git a/examples/gno.land/r/demo/auction/gno.mod b/examples/gno.land/r/demo/auction/gno.mod new file mode 100644 index 00000000000..d35bb8dd08a --- /dev/null +++ b/examples/gno.land/r/demo/auction/gno.mod @@ -0,0 +1,6 @@ +module gno.land/r/demo/auction + +require ( + gno.land/p/demo/auction v0.0.0-latest + gno.land/p/demo/mux v0.0.0-latest +)