From 0d3b82ff2e2a61d1e7254b381fceac72643ed788 Mon Sep 17 00:00:00 2001 From: Mikael VALLENET Date: Thu, 12 Sep 2024 18:17:06 +0200 Subject: [PATCH] feat(grc20-launchpad): realms & pkg (#1263) * feat(erc20-launchpad): initialize token factory realm * feat(erc20-launchpad): initialize gno mod * feat(erc20-launchpad): add totalSupplyCap, allowMint and allowBurn to grc20factory * feat: add 2.5% fee at creation of grc20 token * feat: add airdrop contract * feat(launchpad-grc20): add redeem function in airdrop contract * fix(launchpad-grc20): resolve syntax errors raised by gnovm * fix(launchpad-grc20): resolve syntax errors raised by gnovm * feat(launchpad-grc20): add render in a separate file and set a welcome message on empty path * chore(launchpad-grc20): gofumpt * feat(launchpad-grc20): add mux router and define the render routes structure * feat(launchpad-grc20): add rendering of token details * feat(launchpad-grc20): add rendering of tokens balance for each user * feat(launchpad-grc20): add redeem test of airdrop * chore(launchpad-grc20): run gofumpt * feat(launchpad-grc20): add render of airdrop details & claim check page * feat(launchpad-grc20): add start & end date for airdrop * fix(launchpad-grc20): save timestamp end & start into the airdrop struct instance * feat(launchpad-grc20): add sale realm w/ creating and buying mechanism * feat(launchpad-grc20): add sale render functions * tests(launchpad-grc20): add airdrop tests * tests(launchpad-grc20): add new token tests * tests(launchpad-grc20): add mint token tests * tests(launchpad-grc20): add burn token tests * feat(launchpad-grc20): add buy function * feat(launchpad-grc20): add refund mechanism for sale when not reaching the minimum goal * fix(launchpad-grc20): fix sales syntax errors * tests(launchpad-grc20): add sale creation test * tests(launchpad-grc20): add sale buy & finalize test * feat(launchpad-grc20): add ownable factory admin that can edit factory vault addr * feat(launchpad-grc20): add optional merkle tree whitelist in sale * tests(launchpad-grc20): add private sale test creation & buy mechanism * chore(launchpad-grc20): run gofumpt & fix lint errors * tests(launchpad-grc20): implement interface grc20.Token on our token type * tests(launchpad-grc20): implement interface grc20.Token on our token type * chore(launchpad-grc20): run gno mod tidy * style(launchpad-grc20): add a page to display balance of a buyer for a specific sale * feat(launchpad-grc20): add ClaimJSON function to pass the proofs through the function * feat(launchpad-grc20): add BuyJSON function to pass the proofs through the function * fix(launchpad-grc20): add json & hex imports on sale contract * feat(launchpad-grc20): add render of 5 last tokens, airdrop & sale created & add hyperlinks * feat(launchpad-grc20): add render of airdrops & sales linked to a token & add footer * feat(launchpad-grc20): use seqid instead of basic uint64 * feat(launchpad-grc20): use gnoland/p/demo/merkle pkg & fix seqid display * feat(launchpad-grc20): use a modified verision of merkle tree pkg * fix(launchpad-grc20): initialize the sales & airdrop seqid at 0 * fix(p/demo/merkle): use constructor & position getter * ci(launchpad-grc20): bump gno hash commit --- Makefile | 2 +- gno/p/jsonutil/jsonutil.gno | 8 + gno/r/launchpad_grc20/airdrop_grc20.gno | 131 ++++ gno/r/launchpad_grc20/airdrop_grc20_test.gno | 352 ++++++++++ gno/r/launchpad_grc20/gno.mod | 13 + gno/r/launchpad_grc20/merkleutil.gno | 16 + gno/r/launchpad_grc20/render.gno | 342 +++++++++ gno/r/launchpad_grc20/sale_grc20.gno | 269 +++++++ gno/r/launchpad_grc20/sale_grc20_test.gno | 661 ++++++++++++++++++ gno/r/launchpad_grc20/token_factory_grc20.gno | 180 +++++ .../token_factory_grc20_test.gno | 347 +++++++++ 11 files changed, 2320 insertions(+), 1 deletion(-) create mode 100644 gno/r/launchpad_grc20/airdrop_grc20.gno create mode 100644 gno/r/launchpad_grc20/airdrop_grc20_test.gno create mode 100644 gno/r/launchpad_grc20/gno.mod create mode 100644 gno/r/launchpad_grc20/merkleutil.gno create mode 100644 gno/r/launchpad_grc20/render.gno create mode 100644 gno/r/launchpad_grc20/sale_grc20.gno create mode 100644 gno/r/launchpad_grc20/sale_grc20_test.gno create mode 100644 gno/r/launchpad_grc20/token_factory_grc20.gno create mode 100644 gno/r/launchpad_grc20/token_factory_grc20_test.gno diff --git a/Makefile b/Makefile index 089359f9d4..928b48f5c2 100644 --- a/Makefile +++ b/Makefile @@ -427,7 +427,7 @@ start.gnodev-e2e: .PHONY: clone-gno clone-gno: mkdir -p gnobuild - cd gnobuild && git clone https://github.com/gnolang/gno.git && cd gno && git checkout 9b114172063feaf2da4ae7ebb8263cada3ba699b + cd gnobuild && git clone https://github.com/gnolang/gno.git && cd gno && git checkout 8f800ece85a765113dfa4924da1c06f56865460c cp -r ./gno/p ./gnobuild/gno/examples/gno.land/p/teritori .PHONY: build-gno diff --git a/gno/p/jsonutil/jsonutil.gno b/gno/p/jsonutil/jsonutil.gno index 8bc5c05e68..d455531efa 100644 --- a/gno/p/jsonutil/jsonutil.gno +++ b/gno/p/jsonutil/jsonutil.gno @@ -94,6 +94,14 @@ func MustUint64(value *json.Node) uint64 { return uint64(MustInt(value)) // FIXME: full uint64 range support (currently limited to [-2^63, 2^63-1]) } +func Uint8Node(value uint8) *json.Node { + return json.StringNode("", strconv.FormatUint(uint64(value), 10)) +} + +func MustUint8(value *json.Node) uint8 { + return uint8(MustInt(value)) +} + func AVLTreeNode(root *avl.Tree, transform func(elem interface{}) *json.Node) *json.Node { if root == nil { return EmptyObjectNode() diff --git a/gno/r/launchpad_grc20/airdrop_grc20.gno b/gno/r/launchpad_grc20/airdrop_grc20.gno new file mode 100644 index 0000000000..86c95706e9 --- /dev/null +++ b/gno/r/launchpad_grc20/airdrop_grc20.gno @@ -0,0 +1,131 @@ +package launchpad_grc20 + +import ( + "encoding/hex" + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/json" + "gno.land/p/demo/merkle" + "gno.land/p/demo/seqid" + "gno.land/p/teritori/jsonutil" +) + +type Airdrop struct { + token *Token + merkleRoot string + startTimestamp int64 + endTimestamp int64 + amountPerAddr uint64 + alreadyClaimed *avl.Tree +} + +var ( + airdrops *avl.Tree // airdrop ID -> airdrop + nextAirdropID seqid.ID +) + +func init() { + airdrops = avl.NewTree() + nextAirdropID = seqid.ID(0) +} + +func NewAirdrop(tokenName, merkleRoot string, amountPerAddr uint64, startTimestamp, endTimestamp int64) uint64 { + token := mustGetToken(tokenName) + token.admin.AssertCallerIsOwner() + + if !token.allowMint { + panic("token is not mintable") + } + + now := time.Now().Unix() + if startTimestamp != 0 && startTimestamp < now { + panic("invalid start timestamp, must be in the future or be equal to 0 to start immediately") + } + + if endTimestamp != 0 && endTimestamp < now { + panic("invalid end timestamp, must be in the future or be equal to 0 to never end") + } + + if endTimestamp != 0 && startTimestamp >= endTimestamp { + panic("invalid timestamps, start must be before end") + } + + airdrop := Airdrop{ + token: token, + merkleRoot: merkleRoot, + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + amountPerAddr: amountPerAddr, + alreadyClaimed: avl.NewTree(), + } + + airdropID := nextAirdropID.Next() + token.AirdropsIDs = append(token.AirdropsIDs, airdropID) + + airdrops.Set(airdropID.String(), &airdrop) + + return uint64(airdropID) +} + +func ClaimJSON(airdropID uint64, proofsJSON string) { + nodes, err := json.Unmarshal([]byte(proofsJSON)) + if err != nil { + panic("invalid json proofs") + } + vals := nodes.MustArray() + proofs := make([]merkle.Node, 0, len(vals)) + for _, val := range vals { + obj := val.MustObject() + data, err := hex.DecodeString(obj["hash"].MustString()) + if err != nil { + panic("invalid hex encoded hash") + } + node := merkle.NewNode(data, jsonutil.MustUint8(obj["pos"])) + proofs = append(proofs, node) + } + Claim(airdropID, proofs) +} + +func Claim(airdropID uint64, proofs []merkle.Node) { + airdrop := mustGetAirdrop(airdropID) + caller := std.PrevRealm().Addr() + + if !airdrop.isOnGoing() { + panic("airdrop is not ongoing, look at the airdrop period") + } + + if airdrop.hasAlreadyClaimed(caller) { + panic("already claimed") + } + + leaf := Leaf{[]byte(caller.String())} + if !merkle.Verify(airdrop.merkleRoot, leaf, proofs) { + panic("invalid proof") + } + + airdrop.token.banker.Mint(caller, airdrop.amountPerAddr) + + airdrop.alreadyClaimed.Set(caller.String(), true) +} + +func mustGetAirdrop(airdropID uint64) *Airdrop { + id := seqid.ID(airdropID) + airdropRaw, exists := airdrops.Get(id.String()) + if !exists { + panic("airdrop not found") + } + + return airdropRaw.(*Airdrop) +} + +func (a *Airdrop) hasAlreadyClaimed(caller std.Address) bool { + return a.alreadyClaimed.Has(caller.String()) +} + +func (a *Airdrop) isOnGoing() bool { + now := time.Now().Unix() + return (a.startTimestamp == 0 || a.startTimestamp <= now) && + (a.endTimestamp == 0 || now < a.endTimestamp) +} diff --git a/gno/r/launchpad_grc20/airdrop_grc20_test.gno b/gno/r/launchpad_grc20/airdrop_grc20_test.gno new file mode 100644 index 0000000000..4a18574bb5 --- /dev/null +++ b/gno/r/launchpad_grc20/airdrop_grc20_test.gno @@ -0,0 +1,352 @@ +package launchpad_grc20 + +import ( + "fmt" + "std" + "strconv" + "testing" + "time" + + "gno.land/p/demo/merkle" +) + +func TestNewAirdrop(t *testing.T) { + type testNewAidropInput struct { + tokenName string + root string + amountPerAddr uint64 + start uint64 + end uint64 + } + + type testNewAirdropExpected struct { + panic bool + tokenName string + root string + amountPerAddr uint64 + start uint64 + end uint64 + } + + type testNewAirdrop struct { + input testNewAidropInput + expected testNewAirdropExpected + } + + type testNewAirdropTestTable = map[string]testNewAirdrop + + tests := testNewAirdropTestTable{ + "Success": { + input: testNewAidropInput{ + tokenName: "TestNewAirdropMintableToken", + root: "root", + amountPerAddr: 100, + start: 0, + end: 0, + }, + expected: testNewAirdropExpected{ + panic: false, + tokenName: "TestNewAirdropMintableToken", + root: "root", + amountPerAddr: 100, + start: 0, + end: 0, + }, + }, + "Token that does not exist": { + input: testNewAidropInput{ + tokenName: "ThisTokenDoesNotExist", + root: "root", + amountPerAddr: 100, + start: 0, + end: 0, + }, + expected: testNewAirdropExpected{ + panic: true, + }, + }, + "Token that is not mintable": { + input: testNewAidropInput{ + tokenName: "TestNewAirdropNotMintableToken", + root: "root", + amountPerAddr: 100, + start: 0, + end: 0, + }, + expected: testNewAirdropExpected{ + panic: true, + }, + }, + "Airdrop should start in the future or be equal to 0": { + input: testNewAidropInput{ + tokenName: "TestNewAirdropMintableToken", + root: "root", + amountPerAddr: 100, + start: 100, + end: 0, + }, + expected: testNewAirdropExpected{ + panic: true, + }, + }, + "Airdrop should end in the future or be equal to 0": { + input: testNewAidropInput{ + tokenName: "TestNewAirdropMintableToken", + root: "root", + amountPerAddr: 100, + start: 0, + end: 100, + }, + expected: testNewAirdropExpected{ + panic: true, + }, + }, + "Airdrop should start before it ends": { + input: testNewAidropInput{ + tokenName: "TestNewAirdropMintableToken", + root: "root", + amountPerAddr: 100, + start: 100, + end: 50, + }, + expected: testNewAirdropExpected{ + panic: true, + }, + }, + } + + // Create tokens for testing + NewToken("TestNewAirdropMintableToken", "TestNewAirdropMintableToken", "noimage", 18, 21_000_000, 23_000_000, true, true) + NewToken("TestNewAirdropNotMintableToken", "TestNewAirdropNotMintableToken", "noimage", 18, 21_000_000, 23_000_000, false, true) + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + airdropID := NewAirdrop(test.input.tokenName, test.input.root, test.input.amountPerAddr, int64(test.input.start), int64(test.input.end)) + airdrop := mustGetAirdrop(airdropID) + + if !test.expected.panic { + if airdrop.merkleRoot != test.expected.root { + t.Errorf("Expected root to be %s, got %s", test.expected.root, airdrop.merkleRoot) + } + if airdrop.amountPerAddr != test.expected.amountPerAddr { + t.Errorf("Expected amountPerAddr to be %d, got %d", test.expected.amountPerAddr, airdrop.amountPerAddr) + } + if airdrop.startTimestamp != int64(test.expected.start) { + t.Errorf("Expected startTimestamp to be %d, got %d", test.expected.start, airdrop.startTimestamp) + } + if airdrop.endTimestamp != int64(test.expected.end) { + t.Errorf("Expected endTimestamp to be %d, got %d", test.expected.end, airdrop.endTimestamp) + } + } + }) + } +} + +func TestClaimJSON(t *testing.T) { + type testClaimJSONInput struct { + airdropID uint64 + proofs string + } + + type testClaimJSONExpected struct { + panic bool + } + + type testClaimJSON struct { + input testClaimJSONInput + expected testClaimJSONExpected + } + + type testClaimJSONTestTable = map[string]testClaimJSON + + leaves := []merkle.Hashable{ + Leaf{[]byte("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg")}, + Leaf{[]byte("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv")}, + } + + tree := merkle.NewTree(leaves) + root := tree.Root() + t.Logf("Root: %s", root) + proofs, _ := tree.Proof(leaves[0]) + + erroneousProofs := []merkle.Node{ + {[]byte("badproof")}, + {[]byte("badproof")}, + } + + now := time.Now().Unix() + NewToken("TestClaimJSONAirDropToken", "TestClaimJSONAirDropToken", "noimage", 18, 21_000_000, 23_000_000, true, true) + airdropID := NewAirdrop("TestClaimJSONAirDropToken", root, 100, 0, 0) + + proofsJSON := "[" + for i, proof := range proofs { + proofsJSON += fmt.Sprintf("{\"hash\":\"%s\", \"pos\":\"%s\"}", proof.Hash(), strconv.Itoa(int(proof.Position()))) + if i != len(proofs)-1 { + proofsJSON += ", " + } + } + proofsJSON += "]" + t.Logf("Proofs JSON: %s", proofsJSON) + + tests := testClaimJSONTestTable{ + "Success": { + input: testClaimJSONInput{ + airdropID: airdropID, + proofs: proofsJSON, + }, + expected: testClaimJSONExpected{ + panic: false, + }, + }, + "Bad JSON format": { + input: testClaimJSONInput{ + airdropID: airdropID, + proofs: "badjson", + }, + expected: testClaimJSONExpected{ + panic: true, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + std.TestSetOrigCaller(std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg")) + ClaimJSON(test.input.airdropID, test.input.proofs) + }) + } +} + +func TestClaim(t *testing.T) { + type testClaimInput struct { + airdropID uint64 + addr std.Address + proofs []merkle.Node + } + + type testClaimExpected struct { + panic bool + balance uint64 + } + + type testClaim struct { + input testClaimInput + expected testClaimExpected + } + + type testClaimTestTable = map[string]testClaim + + leaves := []merkle.Hashable{ + Leaf{[]byte("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg")}, + Leaf{[]byte("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv")}, + } + + tree := merkle.NewTree(leaves) + root := tree.Root() + proofs, _ := tree.Proof(leaves[0]) + + erroneousProofs := []merkle.Node{ + {[]byte("badproof")}, + {[]byte("badproof")}, + } + + now := time.Now().Unix() + std.TestSetOrigCaller(std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv")) + NewToken("TestClaimAirDropToken", "TestClaimAirDropToken", "noimage", 18, 21_000_000, 23_000_000, true, true) + notStartedAirDropID := NewAirdrop("TestClaimAirDropToken", root, 100, now+100, now+200) + airdropID := NewAirdrop("TestClaimAirDropToken", root, 100, 0, 0) + + tests := testClaimTestTable{ + "Success": { + input: testClaimInput{ + airdropID: airdropID, + addr: std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg"), + proofs: proofs, + }, + expected: testClaimExpected{ + panic: false, + balance: 100, + }, + }, + "Already claimed": { + input: testClaimInput{ + airdropID: airdropID, + addr: std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg"), + proofs: proofs, + }, + expected: testClaimExpected{ + panic: true, + }, + }, + "Invalid proof": { + input: testClaimInput{ + airdropID: airdropID, + addr: std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg"), + proofs: erroneousProofs, + }, + expected: testClaimExpected{ + panic: true, + }, + }, + "Invalid addr": { + input: testClaimInput{ + airdropID: airdropID, + addr: std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv"), + proofs: proofs, + }, + expected: testClaimExpected{ + panic: true, + }, + }, + "Airdrop not ongoing": { + input: testClaimInput{ + airdropID: notStartedAirDropID, + addr: std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg"), + proofs: proofs, + }, + expected: testClaimExpected{ + panic: true, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + std.TestSetOrigCaller(test.input.addr) + Claim(test.input.airdropID, test.input.proofs) + airdrop := mustGetAirdrop(test.input.airdropID) + + if !test.expected.panic { + if !airdrop.hasAlreadyClaimed(test.input.addr) { + t.Errorf("Expected address be set as claimed, but it is not") + } + if airdrop.token.banker.BalanceOf(test.input.addr) != test.expected.balance { + t.Errorf("Expected balance to be %d, got %d", test.expected.balance, airdrop.token.banker.BalanceOf(test.input.addr)) + } + } + }) + } +} diff --git a/gno/r/launchpad_grc20/gno.mod b/gno/r/launchpad_grc20/gno.mod new file mode 100644 index 0000000000..fbda353088 --- /dev/null +++ b/gno/r/launchpad_grc20/gno.mod @@ -0,0 +1,13 @@ +module gno.land/r/teritori/launchpad_grc20 + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/grc/grc20 v0.0.0-latest + gno.land/p/demo/json v0.0.0-latest + gno.land/p/demo/merkle v0.0.0-latest + gno.land/p/demo/mux v0.0.0-latest + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/seqid v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/teritori/jsonutil v0.0.0-latest +) diff --git a/gno/r/launchpad_grc20/merkleutil.gno b/gno/r/launchpad_grc20/merkleutil.gno new file mode 100644 index 0000000000..fe6b9b1377 --- /dev/null +++ b/gno/r/launchpad_grc20/merkleutil.gno @@ -0,0 +1,16 @@ +package launchpad_grc20 + +// Implementation of Hashable interface from gno/p/demo/merkle +// +// type Hashable interface { +// Bytes() []byte +// } +// + +type Leaf struct { + data []byte +} + +func (l Leaf) Bytes() []byte { + return l.data +} diff --git a/gno/r/launchpad_grc20/render.gno b/gno/r/launchpad_grc20/render.gno new file mode 100644 index 0000000000..043e75a7d4 --- /dev/null +++ b/gno/r/launchpad_grc20/render.gno @@ -0,0 +1,342 @@ +package launchpad_grc20 + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/mux" + "gno.land/p/demo/ufmt" +) + +var router *mux.Router + +const ( + HOME_PATH = "" + + TOKEN_PATH = "token" + TOKEN_DETAIL_PATH = "token/{name}" // {name} is a placeholder for the token name + TOKEN_BALANCE_PATH = "token/{name}/balance/{address}" // {name} is a placeholder for the token name, {address} is a placeholder for the address of the account + + AIRDROP_PATH = "airdrop" + AIRDROP_DETAIL_PATH = "airdrop/{id}" // {id} is a placeholder for the airdrop id + AIRDROP_CLAIM_CHECK_PATH = "airdrop/{id}/claim/{address}" // {id} is a placeholder for the airdrop id, {address} is a placeholder for the address of the account + + SALE_PATH = "sale" + SALE_DETAIL_PATH = "sale/{id}" // {id} is a placeholder for the sale id + SALE_BALANCE_PATH = "sale/{id}/balance/{address}" // {id} is a placeholder for the sale id, {address} is a placeholder for the address of the account +) + +func init() { + router = mux.NewRouter() + router.HandleFunc(HOME_PATH, renderHomePage) + router.HandleFunc(TOKEN_PATH, renderTokenPage) + router.HandleFunc(TOKEN_DETAIL_PATH, renderTokenDetailPage) + router.HandleFunc(TOKEN_BALANCE_PATH, renderTokenBalancePage) + router.HandleFunc(AIRDROP_PATH, renderAirdropPage) + router.HandleFunc(AIRDROP_DETAIL_PATH, renderAirdropDetailPage) + router.HandleFunc(AIRDROP_CLAIM_CHECK_PATH, renderAirdropClaimCheckPage) + router.HandleFunc(SALE_PATH, renderSalePage) + router.HandleFunc(SALE_DETAIL_PATH, renderSaleDetailPage) + router.HandleFunc(SALE_BALANCE_PATH, renderSaleBalancePage) +} + +func renderTokenPage(res *mux.ResponseWriter, req *mux.Request) { + res.Write("# 🪙 Tokens GRC20 Homepage 🪙\n") + + res.Write("A GRC20 token is a digital asset standard on the Gno Chain, similar to ERC20 tokens on Ethereum.\nIt defines a set of rules for creating and managing fungible tokens, ensuring compatibility across the Gno ecosystem.\n\n") + res.Write("## Usage\n") + res.Write("You can create your own token by referring to the help section in the documentation and using the NewToken function.\nAfter creating your token, you can manage it easily through the provided interface.\nTo view details of any token created by this factory, simply visit the page ``:token/{name}``, replacing ``{name}`` with the token's name.\n") + + if len(lastTokensIDcreated) > 0 { + res.Write("## Last tokens created\n") + + for _, tokenIDs := range lastTokensIDcreated { + token := mustGetToken(tokenIDs) + res.Write(ufmt.Sprintf("### Name: %s - Symbol: %s\n", token.banker.GetName(), token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Total Supply: %d %s\n", token.banker.TotalSupply(), token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Decimals: %d\n", token.banker.GetDecimals())) + res.Write(ufmt.Sprintf("#### Admin: %s\n\n", token.admin.Owner().String())) + res.Write(ufmt.Sprintf("> Link: [:token/%s](launchpad_grc20:token/%s)\n\n", tokenIDs, tokenIDs)) + } + } + renderFooter(res, "") +} + +func renderTokenDetailPage(res *mux.ResponseWriter, req *mux.Request) { + tokenName := req.GetVar("name") + token := mustGetToken(tokenName) + + res.Write("# 🪙 Token Details 🪙\n") + + res.Write(ufmt.Sprintf("### Name: %s - Symbol: %s\n", token.banker.GetName(), token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Total Supply: %d %s\n", token.banker.TotalSupply(), token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Decimals: %d\n", token.banker.GetDecimals())) + res.Write(ufmt.Sprintf("#### Admin: %s\n\n", token.admin.Owner().String())) + res.Write(ufmt.Sprintf("#### Total Supply Cap (0 = unlimited): %d %s\n\n", token.totalSupplyCap, token.banker.GetSymbol())) + + if token.allowMint { + res.Write("#### Mintable: true\n\n") + } else { + res.Write("#### Mintable: false\n\n") + } + + if token.allowBurn { + res.Write("#### Burnable: true\n\n") + } else { + res.Write("#### Burnable: false\n\n") + } + + res.Write("## List of Airdrops\n") + for _, id := range token.AirdropsIDs { + airdrop := mustGetAirdrop(uint64(id)) + res.Write(ufmt.Sprintf("### Airdrop #%d\n", uint64(id))) + res.Write(ufmt.Sprintf("#### Merkle Root: %s\n", airdrop.merkleRoot)) + if airdrop.startTimestamp > 0 { + res.Write(ufmt.Sprintf("#### Start Date: %s\n", time.Unix(airdrop.startTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if airdrop.endTimestamp > 0 { + res.Write(ufmt.Sprintf("#### End Date: %s\n", time.Unix(airdrop.endTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + res.Write(ufmt.Sprintf("#### Amount to claim per address: %d\n", airdrop.amountPerAddr)) + res.Write(ufmt.Sprintf("#### Total addresses claimed: %d\n\n", airdrop.alreadyClaimed.Size())) + res.Write(ufmt.Sprintf("> Link: [:airdrop/%d](../launchpad_grc20:airdrop/%d)\n\n", uint64(id), uint64(id))) + } + + res.Write("## List of Sales\n") + for _, id := range token.SalesIDs { + sale := mustGetSale(uint64(id)) + res.Write(ufmt.Sprintf("### Sale #%d\n", uint64(id))) + res.Write(ufmt.Sprintf("#### Price per token: %d $GNOT\n", sale.pricePerToken)) + res.Write(ufmt.Sprintf("#### Limit per address: %d $%s\n", sale.limitPerAddr, token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Min goal: %d $GNOT\n", sale.minGoal)) + res.Write(ufmt.Sprintf("#### Max goal: %d $GNOT\n", sale.maxGoal)) + res.Write(ufmt.Sprintf("#### Already sold: %d $GNOT\n\n", sale.alreadySold)) + res.Write(ufmt.Sprintf("> Link: [:sale/%d](../launchpad_grc20:sale/%d)\n\n", uint64(id), uint64(id))) + } + renderFooter(res, "../") +} + +func renderTokenBalancePage(res *mux.ResponseWriter, req *mux.Request) { + tokenName := req.GetVar("name") + address := req.GetVar("address") + token := mustGetToken(tokenName) + balance := token.banker.BalanceOf(std.Address(address)) + + res.Write("# 🪙 Token Balance 🪙\n") + res.Write(ufmt.Sprintf("### 📍 Address: %s\n ### 🏦 Balance: %d %s\n", address, balance, token.banker.GetSymbol())) + renderFooter(res, "../../../") +} + +func renderAirdropPage(res *mux.ResponseWriter, req *mux.Request) { + res.Write("# 🎁 GRC20 Token Airdrops 🎁\n") + + res.Write("An airdrop is a distribution of GRC20 tokens to multiple wallet addresses, often as part of a promotional campaign or community incentive on the Gno Chain.\n") + res.Write("Airdrops are commonly used to raise awareness, reward loyal users, or distribute tokens in a decentralized manner.\n\n") + res.Write("## Usage\n") + res.Write("You can create a new airdrop by referring to the help section in the documentation and using the NewAirdrop function.\n") + res.Write("To participate in an airdrop, your addresses have to be included in the whitelist. Then you can claim your tokens.\n") + res.Write("For more detailed information about a specific airdrop, including eligibility criteria and distribution details, visit the page ``:airdrop/{id}``, replacing ``{id}`` with the airdrop's unique identifier.\n") + if uint64(nextAirdropID) > 0 { + res.Write("## Last airdrops created\n") + for i := int(nextAirdropID); i > int(nextAirdropID)-5; i-- { + if i < 1 { + break + } + airdrop := mustGetAirdrop(uint64(i)) + res.Write(ufmt.Sprintf("### Airdrop #%d\n", i)) + res.Write(ufmt.Sprintf("#### Token: %s\n", airdrop.token.banker.GetName())) + if airdrop.isOnGoing() { + res.Write("#### Status: Ongoing\n") + } else { + res.Write("#### Status: Ended\n") + } + if airdrop.startTimestamp > 0 { + res.Write(ufmt.Sprintf("#### Start Date: %s\n", time.Unix(airdrop.startTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if airdrop.endTimestamp > 0 { + res.Write(ufmt.Sprintf("#### End Date: %s\n", time.Unix(airdrop.endTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + res.Write(ufmt.Sprintf("#### Amount to claim per address: %d\n", airdrop.amountPerAddr)) + res.Write(ufmt.Sprintf("#### Total addresses claimed: %d\n\n", airdrop.alreadyClaimed.Size())) + res.Write(ufmt.Sprintf("> Link: [:airdrop/%d](launchpad_grc20:airdrop/%d)\n\n", i, i)) + } + } + + res.Write(ufmt.Sprintf("### A total of %d airdrops have been created\n", uint64(nextAirdropID))) + renderFooter(res, "") +} + +func renderAirdropDetailPage(res *mux.ResponseWriter, req *mux.Request) { + airdropID, err := strconv.Atoi(req.GetVar("id")) + if err != nil { + panic("invalid airdrop ID") + } + airdrop := mustGetAirdrop(uint64(airdropID)) + + res.Write(ufmt.Sprintf("# 🎁 Airdrop #%d Details 🎁\n", airdropID)) + + res.Write(ufmt.Sprintf("### Token: %s\n", airdrop.token.banker.GetName())) + if airdrop.isOnGoing() { + res.Write("### Status: Ongoing\n") + } else { + res.Write("### Status: Ended\n") + } + if airdrop.startTimestamp > 0 { + res.Write(ufmt.Sprintf("### Start Date: %s\n", time.Unix(airdrop.startTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if airdrop.endTimestamp > 0 { + res.Write(ufmt.Sprintf("### End Date: %s\n", time.Unix(airdrop.endTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + res.Write(ufmt.Sprintf("### Amount to claim per address: %d\n", airdrop.amountPerAddr)) + res.Write(ufmt.Sprintf("### Merkle Root: %s\n", airdrop.merkleRoot)) + res.Write(ufmt.Sprintf("### Total addresses claimed: %d\n\n", airdrop.alreadyClaimed.Size())) + + res.Write("## Claim Instructions\n") + res.Write("To try to claim your tokens, call the claim function with the airdrop ID and the proof of your address.\n") + res.Write("If you are eligible, you will receive the tokens in your wallet.\n") + renderFooter(res, "../") +} + +func renderAirdropClaimCheckPage(res *mux.ResponseWriter, req *mux.Request) { + airdropID, err := strconv.Atoi(req.GetVar("id")) + if err != nil { + panic("invalid airdrop ID") + } + address := req.GetVar("address") + airdrop := mustGetAirdrop(uint64(airdropID)) + + res.Write(ufmt.Sprintf("# 🎁 Airdrop #%d Claim Check 🎁\n", airdropID)) + res.Write(ufmt.Sprintf("### Address: %s\n", address)) + res.Write(ufmt.Sprintf("### Amount to claim: %d\n", airdrop.amountPerAddr)) + if airdrop.hasAlreadyClaimed(std.Address(address)) { + res.Write("### Address already claimed\n") + } else { + res.Write("### Address not yet claimed or not eligible\n") + } + renderFooter(res, "../../../") +} + +func renderSalePage(res *mux.ResponseWriter, req *mux.Request) { + res.Write("# 🛒 GRC20 Token Sales 🛒\n") + + res.Write("A token sale is a public or private fundraising event where a new GRC20 token is offered to the public at a fixed price.\n") + res.Write("Sales can be used to raise capital for a project, distribute tokens to a wide audience, or establish a market value for the token.\n\n") + res.Write("## Usage\n") + res.Write("You can create a new sale by referring to the help section in the documentation and using the NewSale function.\n") + res.Write("To participate in a token sale, you can buy tokens at the specified price during the sale period. if the sale if private your addr have to be included in the whitelist\n") + res.Write("For more detailed information about a specific sale, including the price per token, sale limits, and goals, visit the page ``:sale/{id}``, replacing ``{id}`` with the sale's unique identifier.\n") + + if uint64(nextSaleID) > 0 { + res.Write("## Last sales created\n") + for i := int(nextSaleID); i > int(nextSaleID)-5; i-- { + if i < 1 { + break + } + sale := mustGetSale(uint64(i)) + res.Write(ufmt.Sprintf("### Sale #%d\n", i)) + res.Write(ufmt.Sprintf("#### Token: %s\n", sale.token.banker.GetName())) + if sale.isOnGoing() { + res.Write("#### Status: Ongoing\n") + } else { + res.Write("#### Status: Not Started or Ended\n") + } + if sale.startTimestamp > 0 { + res.Write(ufmt.Sprintf("#### Start Date: %s\n", time.Unix(sale.startTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if sale.endTimestamp > 0 { + res.Write(ufmt.Sprintf("#### End Date: %s\n", time.Unix(sale.endTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if sale.merkleRoot != "" { + res.Write("#### Sale is private\n") + } else { + res.Write("#### Sale is public\n") + } + res.Write(ufmt.Sprintf("#### Price per token: %d $GNOT\n", sale.pricePerToken)) + res.Write(ufmt.Sprintf("#### Limit per address: %d $%s\n", sale.limitPerAddr, sale.token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("#### Min goal: %d $GNOT\n", sale.minGoal)) + res.Write(ufmt.Sprintf("#### Max goal: %d $GNOT\n", sale.maxGoal)) + res.Write(ufmt.Sprintf("#### Already sold: %d $GNOT\n\n", sale.alreadySold)) + res.Write(ufmt.Sprintf("> Link: [:sale/%d](launchpad_grc20:sale/%d)\n\n", i, i)) + } + } + res.Write(ufmt.Sprintf("### A total of %d sales have been created\n", uint64(nextSaleID))) + renderFooter(res, "") +} + +func renderSaleDetailPage(res *mux.ResponseWriter, req *mux.Request) { + saleID, err := strconv.Atoi(req.GetVar("id")) + if err != nil { + panic("invalid sale ID") + } + sale := mustGetSale(uint64(saleID)) + + res.Write(ufmt.Sprintf("# 🛒 Sale #%d Details 🛒\n", saleID)) + + res.Write(ufmt.Sprintf("### Token: %s\n", sale.token.banker.GetName())) + if sale.isOnGoing() { + res.Write("### Status: Ongoing\n") + } else { + res.Write("### Status: Not Started or Ended\n") + } + if sale.startTimestamp > 0 { + res.Write(ufmt.Sprintf("### Start Date: %s\n", time.Unix(sale.startTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if sale.endTimestamp > 0 { + res.Write(ufmt.Sprintf("### End Date: %s\n", time.Unix(sale.endTimestamp, 0).Format("2006-01-02 15:04:05"))) + } + if sale.merkleRoot != "" { + res.Write("### Sale is private\n") + } else { + res.Write("### Sale is public\n") + } + res.Write(ufmt.Sprintf("### Price per token: %d $GNOT\n", sale.pricePerToken)) + res.Write(ufmt.Sprintf("### Limit per address: %d $%s\n", sale.limitPerAddr, sale.token.banker.GetSymbol())) + res.Write(ufmt.Sprintf("### Min goal: %d $GNOT\n", sale.minGoal)) + res.Write(ufmt.Sprintf("### Max goal: %d $GNOT\n", sale.maxGoal)) + res.Write(ufmt.Sprintf("### Already sold: %d $GNOT\n\n", sale.alreadySold)) + + res.Write("## Buy Instructions\n") + res.Write("To participate in the sale, call the buy function with the sale ID and the amount of tokens you want to buy.\n") + res.Write("If the sale is ongoing and you meet the criteria, you will receive the tokens in your wallet.\n") + renderFooter(res, "../") +} + +func renderSaleBalancePage(res *mux.ResponseWriter, req *mux.Request) { + saleID, err := strconv.Atoi(req.GetVar("id")) + if err != nil { + panic("invalid sale ID") + } + address := req.GetVar("address") + sale := mustGetSale(uint64(saleID)) + balance := sale.BalanceOf(std.Address(address)) + + res.Write("# 🛒 Sale Balance 🛒\n") + res.Write(ufmt.Sprintf("### 🛒 Sale ID: %d\n", saleID)) + res.Write(ufmt.Sprintf("### 📍 Address: %s\n ### 🏦 Balance (Tokens from this sale only): %d %s\n", address, balance, sale.token.banker.GetSymbol())) + + res.Write("> ⚠️ *The tokens will be transfered or refunded after the sale ends depending if the sale reached the min goal or not* ⚠️\n") + renderFooter(res, "../../../") +} + +func renderHomePage(res *mux.ResponseWriter, req *mux.Request) { + res.Write("# Welcome to the GRC20 Launchpad\n This is a platform for launching GRC20 tokens and managing airdrops.\n You can create a new token, mint and burn tokens, and create airdrops and sales periods. \n\n## Available Actions\n- [:token](launchpad_grc20:token)\n- [:airdrop](launchpad_grc20:airdrop)\n- [:sale](launchpad_grc20:sale)\n\n\n*Note: An enhanced user interface is available on [Teritori](https://teritori.com/)*\n") +} + +func renderFooter(res *mux.ResponseWriter, prefixPath string) { + res.Write("\n\n---\n\n") + res.Write("## Links\n") + res.Write(ufmt.Sprintf("- [Home](%slaunchpad_grc20)\n", prefixPath)) + res.Write(ufmt.Sprintf("- [Tokens](%slaunchpad_grc20:token)\n", prefixPath)) + res.Write(ufmt.Sprintf("- [Airdrops](%slaunchpad_grc20:airdrop)\n", prefixPath)) + res.Write(ufmt.Sprintf("- [Sales](%slaunchpad_grc20:sale)\n", prefixPath)) + res.Write(ufmt.Sprintf("\n\n---\n\n")) + res.Write("## About\n") + res.Write("This page was generated using the Gno Chain Launchpad GRC20 module.\n") + res.Write("For more information, visit the [Gno Chain documentation](https://gno.land/docs/).\n") + res.Write("You can experience the full power of the launchpad on [teritori.com](https://teritori.com/).\n") +} + +func Render(path string) string { + return router.Render(path) +} diff --git a/gno/r/launchpad_grc20/sale_grc20.gno b/gno/r/launchpad_grc20/sale_grc20.gno new file mode 100644 index 0000000000..b8f62e3dc3 --- /dev/null +++ b/gno/r/launchpad_grc20/sale_grc20.gno @@ -0,0 +1,269 @@ +package launchpad_grc20 + +import ( + "encoding/hex" + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/json" + "gno.land/p/demo/merkle" + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" + "gno.land/p/teritori/jsonutil" +) + +type Sale struct { + token *Token + startTimestamp int64 + endTimestamp int64 + pricePerToken uint64 + alreadySold uint64 + limitPerAddr uint64 + minGoal uint64 + maxGoal uint64 + owner std.Address + buyers *avl.Tree // address -> amount + finalized bool + merkleRoot string +} + +var ( + sales *avl.Tree // sale ID -> sale + nextSaleID seqid.ID +) + +func init() { + sales = avl.NewTree() + nextSaleID = seqid.ID(0) +} + +func NewSale(tokenName, merkleRoot string, startTimestamp, endTimestamp int64, pricePerToken, limitPerAddr, minGoal, maxGoal uint64, mintToken bool) uint64 { + token := mustGetToken(tokenName) + token.admin.AssertCallerIsOwner() + + if mintToken && !token.allowMint { + panic("token is not mintable") + } + + owner := std.PrevRealm().Addr() + + // Subtract 10 seconds to make it easier to create a sale that starts immediately (for testing purposes) + now := time.Now().Unix() + if startTimestamp < now-10 { + panic("start timestamp must be in the future") + } + + if startTimestamp >= endTimestamp { + panic("invalid timestamps, start must be before end") + } + + if minGoal > maxGoal { + panic("min goal must be less than max goal") + } + + if pricePerToken == 0 { + panic("price per token must be greater than 0") + } + + realmAddr := std.CurrentRealm().Addr() + if mintToken { + token.banker.Mint(realmAddr, maxGoal) + } else { + err := token.banker.Transfer(owner, realmAddr, maxGoal) + if err != nil { + panic("error while transferring tokens to the realm, " + err.Error()) + } + } + + sale := Sale{ + token: token, + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: pricePerToken, + limitPerAddr: limitPerAddr, + minGoal: minGoal, + maxGoal: maxGoal, + owner: owner, + buyers: avl.NewTree(), + finalized: false, + merkleRoot: merkleRoot, + } + + saleID := nextSaleID.Next() + token.SalesIDs = append(token.SalesIDs, saleID) + + sales.Set(saleID.String(), &sale) + + return uint64(saleID) +} + +func BuyJSON(saleID, amount uint64, proofsJSON string) { + if proofsJSON != "" { + nodes, err := json.Unmarshal([]byte(proofsJSON)) + if err != nil { + panic("invalid json proofs") + } + + vals := nodes.MustArray() + proofs := make([]merkle.Node, 0, len(vals)) + for _, val := range vals { + obj := val.MustObject() + data, err := hex.DecodeString(obj["hash"].MustString()) + if err != nil { + panic("invalid hex encoded hash") + } + node := merkle.NewNode(data, jsonutil.MustUint8(obj["pos"])) + proofs = append(proofs, node) + } + Buy(saleID, amount, proofs) + return + } + Buy(saleID, amount, nil) +} + +func Buy(saleID, amount uint64, proofs []merkle.Node) { + buyer := std.GetOrigCaller() + sale := mustGetSale(saleID) + sale.buy(buyer, amount, proofs) +} + +func Finalize(saleID uint64) { + sale := mustGetSale(saleID) + realmAddr := std.CurrentRealm().Addr() + banker := std.GetBanker(std.BankerTypeRealmSend) + + if sale.isOnGoing() { + panic("sale is still ongoing, wait for the end") + } + + if sale.finalized { + panic("sale already finalized") + } + + // If the min goal is not reached, refund all the buyers and send the tokens back to the owner + if sale.alreadySold < sale.minGoal { + sale.refundAllBuyers() + err := sale.token.banker.Transfer(realmAddr, sale.owner, sale.alreadySold) + if err != nil { + panic("error while transferring back tokens to the owner, " + err.Error()) + } + } else { + sale.payAllBuyers() + totalCoins := std.NewCoins(std.NewCoin("ugnot", int64(sale.alreadySold*sale.pricePerToken))) + banker.SendCoins(realmAddr, sale.owner, totalCoins) + } + + sale.finalized = true +} + +func mustGetSale(saleID uint64) *Sale { + id := seqid.ID(saleID) + sale, exists := sales.Get(id.String()) + if !exists { + panic("sale not found") + } + return sale.(*Sale) +} + +func (s *Sale) isOnGoing() bool { + return s.startTimestamp <= time.Now().Unix() && (s.endTimestamp == 0 || time.Now().Unix() < s.endTimestamp) +} + +func (s *Sale) BalanceOf(addr std.Address) uint64 { + balance, exists := s.buyers.Get(addr.String()) + if !exists { + return 0 + } + return balance.(uint64) +} + +func (s *Sale) buy(buyer std.Address, amount uint64, proofs []merkle.Node) { + sentCoins := std.GetOrigSend() + + if s.merkleRoot != "" { + if len(proofs) == 0 { + panic("This sale is private, please provide merkle proofs") + } + + leaf := Leaf{[]byte(buyer.String())} + if !merkle.Verify(s.merkleRoot, leaf, proofs) { + panic("This sale is private, invalid merkle proofs") + } + } + + if len(sentCoins) == 0 { + panic("Please send amount * price per token gnot coins, price per token is " + ufmt.Sprintf("%d", s.pricePerToken) + " $GNOT") + } + + if len(sentCoins) != 1 { + panic("Please send only one type of coin, should be GNOT coins") + } + + sentCoin := sentCoins[0] + + banker := std.GetBanker(std.BankerTypeOrigSend) + realmAddr := std.CurrentRealm().Addr() + + total := amount + alreadyBought, exists := s.buyers.Get(buyer.String()) + if exists { + total += alreadyBought.(uint64) + } + + if !s.isOnGoing() { + panic("sale is not ongoing") + } + + if amount == 0 { + panic("amount must be greater than 0") + } + + if total > s.limitPerAddr { + panic("amount exceeds limit per address") + } + + if s.alreadySold+amount > s.maxGoal { + panic("amount exceeds max goal of the sale") + } + + minCoins := std.NewCoin("ugnot", int64(amount*s.pricePerToken)) + if !sentCoin.IsGTE(minCoins) { + panic("Please send enough coins, price per token is " + ufmt.Sprintf("%d", s.pricePerToken) + " $GNOT") + } + + change := sentCoin.Sub(minCoins) + if change.IsPositive() { + banker.SendCoins(realmAddr, buyer, std.NewCoins(change)) + } + + s.buyers.Set(buyer.String(), total) + s.alreadySold += amount +} + +func (s *Sale) refundAllBuyers() { + banker := std.GetBanker(std.BankerTypeRealmSend) + realmAddr := std.CurrentRealm().Addr() + + s.buyers.Iterate("", "", func(key string, value interface{}) bool { + buyer := std.Address(key) + amount := value.(uint64) + refundCoins := std.NewCoins(std.NewCoin("ugnot", int64(amount*s.pricePerToken))) + banker.SendCoins(realmAddr, buyer, refundCoins) + return false + }) +} + +func (s *Sale) payAllBuyers() { + realmAddr := std.CurrentRealm().Addr() + + s.buyers.Iterate("", "", func(key string, value interface{}) bool { + buyer := std.Address(key) + amount := value.(uint64) + err := s.token.banker.Transfer(realmAddr, buyer, amount) + if err != nil { + panic("error while transferring tokens to the buyer, " + err.Error()) + } + return false + }) +} diff --git a/gno/r/launchpad_grc20/sale_grc20_test.gno b/gno/r/launchpad_grc20/sale_grc20_test.gno new file mode 100644 index 0000000000..d94fb5c6b5 --- /dev/null +++ b/gno/r/launchpad_grc20/sale_grc20_test.gno @@ -0,0 +1,661 @@ +package launchpad_grc20 + +import ( + "std" + "testing" + "time" + + "gno.land/p/demo/merkle" +) + +func TestNewSale(t *testing.T) { + type testNewSaleInput struct { + tokenName string + startTimestamp int64 + endTimestamp int64 + pricePerToken uint64 + limitPerAddr uint64 + minGoal uint64 + maxGoal uint64 + mintToken bool + addr std.Address + merkleRoot string + } + + type testNewSaleExpected struct { + panic bool + tokenName string + startTimestamp int64 + endTimestamp int64 + pricePerToken uint64 + limitPerAddr uint64 + minGoal uint64 + maxGoal uint64 + mintToken bool + merkleRoot string + } + + type testNewSale struct { + input testNewSaleInput + expected testNewSaleExpected + } + + type testNewSaleTestTable = map[string]testNewSale + + startTimestamp := time.Now().Unix() + 1000 + endTimestamp := time.Now().Unix() + 2000 + + bob := std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg") + alice := std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv") + + tests := testNewSaleTestTable{ + "Success with mint token": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: false, + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + merkleRoot: "", + }, + }, + "Success without mint token": { + input: testNewSaleInput{ + tokenName: "TestNewSaleNotMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: false, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: false, + tokenName: "TestNewSaleNotMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: false, + merkleRoot: "", + }, + }, + "Success with merkleRoot": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "merkleRoot", + }, + expected: testNewSaleExpected{ + panic: false, + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + merkleRoot: "merkleRoot", + }, + }, + "Fail with token not mintable": { + input: testNewSaleInput{ + tokenName: "TestNewSaleNotMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + "Fail with startTimestamp in the past": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: time.Now().Unix() - 1000, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + "Fail with endTimestamp less than startTimestamp": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: startTimestamp - 1000, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + "Fail with minGoal greater than maxGoal": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 1000, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + "Fail with pricePerToken equal to 0": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 0, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: bob, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + "Fail with not owner of the token": { + input: testNewSaleInput{ + tokenName: "TestNewSaleMintableToken", + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + pricePerToken: 100, + limitPerAddr: 100, + minGoal: 100, + maxGoal: 100, + mintToken: true, + addr: alice, + merkleRoot: "", + }, + expected: testNewSaleExpected{ + panic: true, + }, + }, + } + + std.TestSetOrigCaller(bob) + + NewToken("TestNewSaleMintableToken", "TestNewSaleMintableToken", "image", 18, 21_000_000, 23_000_000, true, true) + NewToken("TestNewSaleNotMintableToken", "TestNewSaleNotMintableToken", "image", 18, 21_000_000, 23_000_000, false, true) + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + std.TestSetOrigCaller(test.input.addr) + saleID := NewSale(test.input.tokenName, test.input.merkleRoot, test.input.startTimestamp, test.input.endTimestamp, test.input.pricePerToken, test.input.limitPerAddr, test.input.minGoal, test.input.maxGoal, test.input.mintToken) + sale := mustGetSale(saleID) + + if !test.expected.panic { + if sale.token.banker.GetName() != test.expected.tokenName { + t.Errorf("Expected tokenName to be %s, got %s", test.expected.tokenName, sale.token.banker.GetName()) + } + if sale.startTimestamp != test.expected.startTimestamp { + t.Errorf("Expected startTimestamp to be %d, got %d", test.expected.startTimestamp, sale.startTimestamp) + } + if sale.endTimestamp != test.expected.endTimestamp { + t.Errorf("Expected endTimestamp to be %d, got %d", test.expected.endTimestamp, sale.endTimestamp) + } + if sale.pricePerToken != test.expected.pricePerToken { + t.Errorf("Expected pricePerToken to be %d, got %d", test.expected.pricePerToken, sale.pricePerToken) + } + if sale.limitPerAddr != test.expected.limitPerAddr { + t.Errorf("Expected limitPerAddr to be %d, got %d", test.expected.limitPerAddr, sale.limitPerAddr) + } + if sale.minGoal != test.expected.minGoal { + t.Errorf("Expected minGoal to be %d, got %d", test.expected.minGoal, sale.minGoal) + } + if sale.maxGoal != test.expected.maxGoal { + t.Errorf("Expected maxGoal to be %d, got %d", test.expected.maxGoal, sale.maxGoal) + } + if sale.token.allowMint != test.expected.mintToken { + t.Errorf("Expected mintToken to be %t, got %t", test.expected.mintToken, sale.token.allowMint) + } + if sale.merkleRoot != test.expected.merkleRoot { + t.Errorf("Expected merkleRoot to be %s, got %s", test.expected.merkleRoot, sale.merkleRoot) + } + } + }) + } +} + +func TestBuy(t *testing.T) { + type testBuyInput struct { + saleID uint64 + amount uint64 + coins std.Coins + addr std.Address + proofs []merkle.Node + } + + type testBuyExpected struct { + panic bool + balance uint64 + } + + type testBuy struct { + input testBuyInput + expected testBuyExpected + } + + type testBuyTestTable = map[string]testBuy + + startTimestamp := time.Now().Unix() + endTimestamp := time.Now().Unix() + 1000 + + bob := std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg") + alice := std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv") + carol := std.Address("g1r69l0vhp7tqle3a0rk8m8fulr8sjvj4h7n0tth") + + leaves := []merkle.Hashable{ + Leaf{[]byte("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg")}, + Leaf{[]byte("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv")}, + } + + tree := merkle.NewTree(leaves) + root := tree.Root() + proofs, _ := tree.Proof(leaves[0]) + + std.TestSetOrigCaller(bob) + + NewToken("TestBuyToken", "TestBuyToken", "image", 18, 21_000_000, 23_000_000, true, true) + NewToken("TestPrivateBuyToken", "TestPrivateBuyToken", "image", 18, 21_000_000, 23_000_000, true, true) + saleID := NewSale("TestBuyToken", "", startTimestamp, endTimestamp, 100, 15, 0, 20, true) + privateSaleID := NewSale("TestPrivateBuyToken", root, startTimestamp, endTimestamp, 100, 15, 0, 20, true) + + coins := std.NewCoins(std.NewCoin("ugnot", 100*10)) + badCoin := std.NewCoins(std.NewCoin("notugnot", 100*10)) + manyCoins := std.NewCoins(std.NewCoin("ugnot", 100*10), std.NewCoin("notugnot", 100*10)) + notEnoughCoins := std.NewCoins(std.NewCoin("ugnot", 100*5)) + tooManyCoins := std.NewCoins(std.NewCoin("ugnot", 100*11)) + emptyCoins := std.NewCoins() + + tests := testBuyTestTable{ + "Success": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: coins, + addr: bob, + proofs: nil, + }, + expected: testBuyExpected{ + panic: false, + balance: 10, + }, + }, + "Success private sale": { + input: testBuyInput{ + saleID: privateSaleID, + amount: 1, + coins: coins, + addr: bob, + proofs: proofs, + }, + expected: testBuyExpected{ + panic: false, + balance: 1, + }, + }, + "Not in the tree / bad proofs": { + input: testBuyInput{ + saleID: privateSaleID, + amount: 1, + coins: coins, + addr: carol, + proofs: proofs, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Limit per addr reached": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: coins, + addr: bob, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + balance: 10, + }, + }, + "Exceeds maximum goal": { + input: testBuyInput{ + saleID: saleID, + amount: 15, + coins: coins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Send empty coins": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: emptyCoins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Send bad coins": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: badCoin, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Send many coins": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: manyCoins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Send not enough coins": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: notEnoughCoins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Amount is 0": { + input: testBuyInput{ + saleID: saleID, + amount: 0, + coins: coins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: true, + }, + }, + "Send too many coins": { + input: testBuyInput{ + saleID: saleID, + amount: 10, + coins: tooManyCoins, + addr: alice, + proofs: nil, + }, + expected: testBuyExpected{ + panic: false, + balance: 10, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + std.TestSetOrigCaller(test.input.addr) + std.TestSetOrigSend(test.input.coins, nil) + Buy(test.input.saleID, test.input.amount, test.input.proofs) + sale := mustGetSale(test.input.saleID) + if !test.expected.panic { + buyer, exists := sale.buyers.Get(test.input.addr.String()) + if !exists { + t.Errorf("Expected buyer to not exist, got %d", buyer) + } + if buyer.(uint64) != test.expected.balance { + t.Errorf("Expected balance to be %d, got %d", test.expected.balance, buyer.(uint64)) + } + } + }) + } +} + +func TestFinalize(t *testing.T) { + type testFinalizeInput struct { + saleID uint64 + buyer std.Address + amount uint64 + skipHeights int64 + } + + type testFinalizeExpected struct { + panic bool + } + + type testFinalize struct { + input testFinalizeInput + expected testFinalizeExpected + } + + type testFinalizeTestTable = map[string]testFinalize + + startTimestamp := time.Now().Unix() - 5 + endTimestamp := time.Now().Unix() - 2 + + onGoingEndTimestamp := time.Now().Unix() + 100 + onGoingEndTimestamp2 := time.Now().Unix() + 200 + + bob := std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg") + alice := std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv") + + std.TestSetOrigCaller(bob) + + NewToken("TestFinalizeToken", "TestFinalizeToken", "image", 18, 21_000_000, 23_000_000, true, true) + saleID := NewSale("TestFinalizeToken", "", startTimestamp, endTimestamp, 100, 15, 10, 20, true) + onGoingSaleID := NewSale("TestFinalizeToken", "", startTimestamp, onGoingEndTimestamp, 100, 15, 10, 20, true) + onGoingSaleID2 := NewSale("TestFinalizeToken", "", startTimestamp, onGoingEndTimestamp2, 100, 15, 10, 20, true) + + tests := testFinalizeTestTable{ + "Success with 0 tokens sold": { + input: testFinalizeInput{ + saleID: saleID, + buyer: alice, + amount: 0, + skipHeights: 0, + }, + expected: testFinalizeExpected{ + panic: false, + }, + }, + "Fail sale not found": { + input: testFinalizeInput{ + saleID: 100, + buyer: bob, + amount: 0, + skipHeights: 0, + }, + expected: testFinalizeExpected{ + panic: true, + }, + }, + "Fail sale already finalized": { + input: testFinalizeInput{ + saleID: saleID, + buyer: bob, + amount: 0, + skipHeights: 0, + }, + expected: testFinalizeExpected{ + panic: true, + }, + }, + "Fail sale still ongoing": { + input: testFinalizeInput{ + saleID: onGoingSaleID, + buyer: bob, + amount: 0, + skipHeights: 0, + }, + expected: testFinalizeExpected{ + panic: true, + }, + }, + "Success with min goal not reached but some token sold": { + input: testFinalizeInput{ + saleID: onGoingSaleID, + buyer: alice, + amount: 2, + skipHeights: 20, // 20 blocks passed ~= 100 seconds (close the onGoingEndTimestamp1) + }, + expected: testFinalizeExpected{ + panic: false, + }, + }, + "Success with min goal reached": { + input: testFinalizeInput{ + saleID: onGoingSaleID2, + buyer: alice, + amount: 15, + skipHeights: 20, // 20 blocks passed ~= 100 seconds again (close the onGoingEndTimestamp2) + }, + expected: testFinalizeExpected{ + panic: false, + }, + }, + } + + banker := std.GetBanker(std.BankerTypeReadonly) + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + if test.expected.panic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + } + + std.TestSetOrigCaller(test.input.buyer) + + buyerBalance := banker.GetCoins(test.input.buyer).AmountOf("ugnot") + sale := mustGetSale(test.input.saleID) + + coins := std.NewCoins(std.NewCoin("ugnot", int64(test.input.amount*sale.pricePerToken))) + std.TestSetOrigSend(coins, nil) + + if test.input.amount != 0 { + Buy(test.input.saleID, test.input.amount, nil) + } + + std.TestSkipHeights(test.input.skipHeights) + + Finalize(test.input.saleID) + + if !test.expected.panic { + if !sale.finalized { + t.Errorf("Expected sale to be finalized, got %t", sale.finalized) + } + + if sale.alreadySold != test.input.amount { + t.Errorf("Expected alreadySold to be %d, got %d", test.input.amount, sale.alreadySold) + } + + if sale.alreadySold < sale.minGoal { + if sale.token.banker.BalanceOf(test.input.buyer) != 0 { + t.Errorf("Expected tokens balance to be 0 since min goal not reach, got %d", sale.token.banker.BalanceOf(test.input.buyer)) + } + + // Since coins come from nowhere in the testing context, the refund just add news coins to addr + if banker.GetCoins(test.input.buyer).AmountOf("ugnot") != buyerBalance+coins.AmountOf("ugnot") { + t.Errorf("Expected money to be refund and be %d since min goal not reach but got %d", buyerBalance, banker.GetCoins(test.input.buyer).AmountOf("ugnot")) + } + } else { + if sale.token.banker.BalanceOf(test.input.buyer) != test.input.amount { + t.Errorf("Expected balance to be %d, got %d", test.input.amount, sale.token.banker.BalanceOf(test.input.buyer)) + } + } + } + }) + } +} diff --git a/gno/r/launchpad_grc20/token_factory_grc20.gno b/gno/r/launchpad_grc20/token_factory_grc20.gno new file mode 100644 index 0000000000..3cee9242cb --- /dev/null +++ b/gno/r/launchpad_grc20/token_factory_grc20.gno @@ -0,0 +1,180 @@ +package launchpad_grc20 + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/ownable" + "gno.land/p/demo/seqid" +) + +type Token struct { + banker *grc20.Banker + admin *ownable.Ownable + image string + totalSupplyCap uint64 + allowMint bool + allowBurn bool + AirdropsIDs []seqid.ID + SalesIDs []seqid.ID +} + +var _ grc20.Token = (*Token)(nil) + +var lastTokensIDcreated = []string{} + +var ( + tokens *avl.Tree // name -> token + factoryAdmin *ownable.Ownable + factoryVault std.Address +) + +// Initialize tori address as admin & vault for fees +func init() { + tokens = avl.NewTree() + factoryAdmin = ownable.NewWithAddress(std.Address("g1ctt28t7sdyp28qzkvlfyx0hyxuz6vz7nplwm9c")) + factoryVault = std.Address("g1ctt28t7sdyp28qzkvlfyx0hyxuz6vz7nplwm9c") +} + +func NewToken(name, symbol, image string, decimals uint, initialSupply, totalSupplyCap uint64, allowMint, allowBurn bool) { + admin := std.PrevRealm().Addr() + + exists := tokens.Has(name) + if exists { + panic("this token already exists") + } + if totalSupplyCap > 0 && initialSupply > totalSupplyCap { + panic("initial supply exceeds total supply cap") + } + if decimals > 18 { + panic("decimals must be 18 or less") + } + + banker := grc20.NewBanker(name, symbol, decimals) + + fee := initialSupply * 25 / 1000 + netSupply := initialSupply - fee + if fee > 0 { + banker.Mint(factoryVault, fee) + } + if netSupply > 0 { + banker.Mint(admin, netSupply) + } + + inst := Token{ + banker: banker, + admin: ownable.NewWithAddress(admin), + image: image, + totalSupplyCap: totalSupplyCap, + allowMint: allowMint, + allowBurn: allowBurn, + AirdropsIDs: []seqid.ID{}, + SalesIDs: []seqid.ID{}, + } + + tokens.Set(name, &inst) + + if len(lastTokensIDcreated) == 5 { + lastTokensIDcreated = lastTokensIDcreated[:4] + } + lastTokensIDcreated = append([]string{name}, lastTokensIDcreated...) +} + +func Mint(name string, to std.Address, amount uint64) { + token := mustGetToken(name) + token.admin.AssertCallerIsOwner() + + if !token.allowMint { + panic("minting is not allowed") + } + + if token.totalSupplyCap > 0 { + totalSupply := token.TotalSupply() + if totalSupply+amount > token.totalSupplyCap { + panic("minting would exceed total supply cap") + } + } + + checkErr(token.banker.Mint(to, amount)) +} + +func Burn(name string, from std.Address, amount uint64) { + token := mustGetToken(name) + token.admin.AssertCallerIsOwner() + if !token.allowBurn { + panic("burning is not allowed") + } + checkErr(token.banker.Burn(from, amount)) +} + +func TotalSupply(name string) uint64 { + token := mustGetToken(name) + return token.TotalSupply() +} + +func BalanceOf(name string, owner std.Address) uint64 { + token := mustGetToken(name) + return token.BalanceOf(owner) +} + +func Allowance(name string, owner, spender std.Address) uint64 { + token := mustGetToken(name) + return token.Allowance(owner, spender) +} + +func Transfer(name string, to std.Address, amount uint64) { + token := mustGetToken(name) + checkErr(token.Transfer(to, amount)) +} + +func Approve(name string, spender std.Address, amount uint64) { + token := mustGetToken(name) + checkErr(token.Approve(spender, amount)) +} + +func TransferFrom(name string, from, to std.Address, amount uint64) { + token := mustGetToken(name) + checkErr(token.TransferFrom(from, to, amount)) +} + +func (token Token) Token() grc20.Token { return token.banker.Token() } +func (token Token) GetName() string { return token.banker.GetName() } +func (token Token) GetSymbol() string { return token.banker.GetSymbol() } +func (token Token) GetDecimals() uint { return token.banker.GetDecimals() } +func (token Token) TotalSupply() uint64 { return token.Token().TotalSupply() } +func (token Token) BalanceOf(owner std.Address) uint64 { return token.Token().BalanceOf(owner) } +func (token Token) Transfer(to std.Address, amount uint64) error { + return token.Token().Transfer(to, amount) +} + +func (token Token) Allowance(owner, spender std.Address) uint64 { + return token.Token().Allowance(owner, spender) +} + +func (token Token) Approve(spender std.Address, amount uint64) error { + return token.Token().Approve(spender, amount) +} + +func (token Token) TransferFrom(from, to std.Address, amount uint64) error { + return token.Token().TransferFrom(from, to, amount) +} + +func SetFactoryVault(vault std.Address) { + factoryAdmin.AssertCallerIsOwner() + factoryVault = vault +} + +func mustGetToken(name string) *Token { + t, exists := tokens.Get(name) + if !exists { + panic("token instance does not exist") + } + return t.(*Token) +} + +func checkErr(err error) { + if err != nil { + panic(err) + } +} diff --git a/gno/r/launchpad_grc20/token_factory_grc20_test.gno b/gno/r/launchpad_grc20/token_factory_grc20_test.gno new file mode 100644 index 0000000000..3e4b76d9e0 --- /dev/null +++ b/gno/r/launchpad_grc20/token_factory_grc20_test.gno @@ -0,0 +1,347 @@ +package launchpad_grc20 + +import ( + "std" + "testing" +) + +func TestNewToken(t *testing.T) { + type testNewTokenInput struct { + name string + symbol string + image string + decimals uint + initial uint64 + maximum uint64 + allowMint bool + allowBurn bool + } + + type testNewTokenExpected struct { + panic bool + name string + symbol string + image string + decimals uint + initial uint64 + maximum uint64 + allowMint bool + allowBurn bool + } + + type testNewToken struct { + input testNewTokenInput + expected testNewTokenExpected + } + + type testNewTokenTestTable = map[string]testNewToken + + tests := testNewTokenTestTable{ + "Success": { + input: testNewTokenInput{ + name: "TestNewToken", + symbol: "TST", + image: "image", + decimals: 18, + initial: 1000000000000000000, + maximum: 1000000000000000000, + allowMint: true, + allowBurn: true, + }, + expected: testNewTokenExpected{ + panic: false, + name: "TestNewToken", + symbol: "TST", + image: "image", + decimals: 18, + initial: 1000000000000000000, + maximum: 1000000000000000000, + allowMint: true, + allowBurn: true, + }, + }, + "Decimals greater than 18": { + input: testNewTokenInput{ + name: "TestNewToken2", + symbol: "TST", + image: "image", + decimals: 19, + initial: 1000000000000000000, + maximum: 1000000000000000000, + allowMint: true, + allowBurn: true, + }, + expected: testNewTokenExpected{ + panic: true, + }, + }, + "Token already exists": { + input: testNewTokenInput{ + name: "TestNewToken", + symbol: "TST", + image: "image", + decimals: 18, + initial: 1000000000000000000, + maximum: 1000000000000000000, + allowMint: true, + allowBurn: true, + }, + expected: testNewTokenExpected{ + panic: true, + }, + }, + "Initial supply exceeds total supply cap": { + input: testNewTokenInput{ + name: "TestNewToken2", + symbol: "TST2", + image: "image", + decimals: 18, + initial: 1000000000000000001, + maximum: 1000000000000000000, + allowMint: true, + allowBurn: true, + }, + expected: testNewTokenExpected{ + panic: true, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + defer func() { + if r := recover(); (r != nil) != test.expected.panic { + t.Errorf("panic = %v, want %v", r != nil, test.expected.panic) + } + }() + + NewToken(test.input.name, test.input.symbol, test.input.image, test.input.decimals, test.input.initial, test.input.maximum, test.input.allowMint, test.input.allowBurn) + + inst := mustGetToken(test.input.name) + if inst.banker.GetName() != test.expected.name { + t.Errorf("name = %v, want %v", inst.banker.GetName(), test.expected.name) + } + if inst.banker.GetSymbol() != test.expected.symbol { + t.Errorf("symbol = %v, want %v", inst.banker.GetSymbol(), test.expected.symbol) + } + if inst.image != test.expected.image { + t.Errorf("image = %v, want %v", inst.image, test.expected.image) + } + if inst.banker.GetDecimals() != test.expected.decimals { + t.Errorf("decimals = %v, want %v", inst.banker.GetDecimals(), test.expected.decimals) + } + if inst.banker.TotalSupply() != test.expected.initial { + t.Errorf("initial = %v, want %v", inst.banker.TotalSupply(), test.expected.initial) + } + if inst.totalSupplyCap != test.expected.maximum { + t.Errorf("maximum = %v, want %v", inst.totalSupplyCap, test.expected.maximum) + } + if inst.allowMint != test.expected.allowMint { + t.Errorf("allowMint = %v, want %v", inst.allowMint, test.expected.allowMint) + } + if inst.allowBurn != test.expected.allowBurn { + t.Errorf("allowBurn = %v, want %v", inst.allowBurn, test.expected.allowBurn) + } + }) + } +} + +func TestMint(t *testing.T) { + type testMintInput struct { + name string + to std.Address + amount uint64 + } + + type testMintExpected struct { + panic bool + totalSupply uint64 + } + + type testMint struct { + input testMintInput + expected testMintExpected + } + + type testMintTestTable = map[string]testMint + + bob := std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg") + alice := std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv") + + std.TestSetOrigCaller(bob) + + NewToken("TestMintTokenMintable", "TestMintTokenMintable", "image", 18, 21_000_000, 23_000_000, true, true) + NewToken("TestMintTokenNotMintable", "TestMintTokenNotMintable", "image", 18, 21_000_000, 23_000_000, false, true) + + tests := testMintTestTable{ + "Success": { + input: testMintInput{ + name: "TestMintTokenMintable", + to: bob, + amount: 1, + }, + expected: testMintExpected{ + panic: false, + totalSupply: 21_000_001, + }, + }, + "Token does not exist": { + input: testMintInput{ + name: "TestToken2", + to: bob, + amount: 1000000000000000000, + }, + expected: testMintExpected{ + panic: true, + }, + }, + "Minting not allowed": { + input: testMintInput{ + name: "TestMintTokenNotMintable", + to: bob, + amount: 1000000000000000000, + }, + expected: testMintExpected{ + panic: true, + }, + }, + "Total supply cap exceeded": { + input: testMintInput{ + name: "TestMintTokenMintable", + to: bob, + amount: 1000000000000000000, + }, + expected: testMintExpected{ + panic: true, + }, + }, + "Is not the owner": { + input: testMintInput{ + name: "TestMintTokenMintable", + to: alice, + amount: 1000000000000000000, + }, + expected: testMintExpected{ + panic: true, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + defer func() { + if r := recover(); (r != nil) != test.expected.panic { + t.Errorf("panic = %v, want %v", r != nil, test.expected.panic) + } + }() + + Mint(test.input.name, test.input.to, test.input.amount) + + inst := mustGetToken(test.input.name) + if inst.banker.TotalSupply() != test.expected.totalSupply { + t.Errorf("totalSupply = %v, want %v", inst.banker.TotalSupply(), test.expected.totalSupply) + } + }) + } +} + +func TestBurn(t *testing.T) { + type testBurnInput struct { + name string + from std.Address + amount uint64 + } + + type testBurnExpected struct { + panic bool + totalSupply uint64 + } + + type testBurn struct { + input testBurnInput + expected testBurnExpected + } + + type testBurnTestTable = map[string]testBurn + + bob := std.Address("g126gx6p6d3da4ymef35ury6874j6kys044r7zlg") + alice := std.Address("g1ld6uaykyugld4rnm63rcy7vju4zx23lufml3jv") + + std.TestSetOrigCaller(bob) + + NewToken("TestBurnTokenBurnable", "TestBurnTokenBurnable", "image", 18, 21_000_000, 23_000_000, true, true) + NewToken("TestBurnTokenNotBurnable", "TestBurnTokenNotBurnable", "image", 18, 21_000_000, 23_000_000, true, false) + + tests := testBurnTestTable{ + "Success": { + input: testBurnInput{ + name: "TestBurnTokenBurnable", + from: bob, + amount: 1, + }, + expected: testBurnExpected{ + panic: false, + totalSupply: 20_999_999, + }, + }, + "Token does not exist": { + input: testBurnInput{ + name: "TestToken2", + from: bob, + amount: 1000000000000000000, + }, + expected: testBurnExpected{ + panic: true, + }, + }, + "Burning not allowed": { + input: testBurnInput{ + name: "TestBurnTokenNotBurnable", + from: bob, + amount: 1000000000000000000, + }, + expected: testBurnExpected{ + panic: true, + }, + }, + "Not enough in balance": { + input: testBurnInput{ + name: "TestBurnTokenBurnable", + from: bob, + amount: 1000000000000000000, + }, + expected: testBurnExpected{ + panic: true, + }, + }, + "Is not the owner": { + input: testBurnInput{ + name: "TestBurnTokenBurnable", + from: alice, + amount: 1000000000000000000, + }, + expected: testBurnExpected{ + panic: true, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + defer func() { + if r := recover(); (r != nil) != test.expected.panic { + t.Errorf("panic = %v, want %v", r != nil, test.expected.panic) + } + }() + + Burn(test.input.name, test.input.from, test.input.amount) + + inst := mustGetToken(test.input.name) + if !test.expected.panic { + if inst.banker.TotalSupply() != test.expected.totalSupply { + t.Errorf("totalSupply = %v, want %v", inst.banker.TotalSupply(), test.expected.totalSupply) + } + } + }) + } +}