diff --git a/examples/gno.land/r/demo/atomicswap/atomicswap.gno b/examples/gno.land/r/demo/atomicswap/atomicswap.gno new file mode 100644 index 00000000000..8862feb1bed --- /dev/null +++ b/examples/gno.land/r/demo/atomicswap/atomicswap.gno @@ -0,0 +1,175 @@ +// Package atomicswap implements a hash time-locked contract (HTLC) for atomic swaps +// between native coins (ugnot) or GRC20 tokens. +// +// An atomic swap allows two parties to exchange assets in a trustless way, where +// either both transfers happen or neither does. The process works as follows: +// +// 1. Alice wants to swap with Bob. She generates a secret and creates a swap with +// Bob's address and the hash of the secret (hashlock). +// +// 2. Bob can claim the assets by providing the correct secret before the timelock expires. +// The secret proves Bob knows the preimage of the hashlock. +// +// 3. If Bob doesn't claim in time, Alice can refund the assets back to herself. +// +// Example usage for native coins: +// +// // Alice creates a swap with 1000ugnot for Bob +// secret := "mysecret" +// hashlock := hex.EncodeToString(sha256.Sum256([]byte(secret))) +// id, _ := atomicswap.NewCoinSwap(bobAddr, hashlock) // -send 1000ugnot +// +// // Bob claims the swap by providing the secret +// atomicswap.Claim(id, "mysecret") +// +// Example usage for GRC20 tokens: +// +// // Alice approves the swap contract to spend her tokens +// token.Approve(swapAddr, 1000) +// +// // Alice creates a swap with 1000 tokens for Bob +// id, _ := atomicswap.NewGRC20Swap(bobAddr, hashlock, "gno.land/r/demo/token") +// +// // Bob claims the swap by providing the secret +// atomicswap.Claim(id, "mysecret") +// +// If Bob doesn't claim in time (default 1 week), Alice can refund: +// +// atomicswap.Refund(id) +package atomicswap + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/ufmt" + "gno.land/r/demo/grc20reg" +) + +const defaultTimelockDuration = 7 * 24 * time.Hour // 1w + +var ( + swaps avl.Tree // id -> *Swap + counter int +) + +// NewCoinSwap creates a new atomic swap contract for native coins. +// It uses a default timelock duration. +func NewCoinSwap(recipient std.Address, hashlock string) (int, *Swap) { + timelock := time.Now().Add(defaultTimelockDuration) + return NewCustomCoinSwap(recipient, hashlock, timelock) +} + +// NewGRC20Swap creates a new atomic swap contract for grc20 tokens. +// It uses gno.land/r/demo/grc20reg to lookup for a registered token. +func NewGRC20Swap(recipient std.Address, hashlock string, tokenRegistryKey string) (int, *Swap) { + timelock := time.Now().Add(defaultTimelockDuration) + tokenGetter := grc20reg.MustGet(tokenRegistryKey) + token := tokenGetter() + return NewCustomGRC20Swap(recipient, hashlock, timelock, token) +} + +// NewCoinSwapWithTimelock creates a new atomic swap contract for native coin. +// It allows specifying a custom timelock duration. +// It is not callable with `gnokey maketx call`, but can be imported by another contract or `gnokey maketx run`. +func NewCustomCoinSwap(recipient std.Address, hashlock string, timelock time.Time) (int, *Swap) { + sender := std.PrevRealm().Addr() + sent := std.GetOrigSend() + require(len(sent) != 0, "at least one coin needs to be sent") + + // Create the swap + sendFn := func(to std.Address) { + banker := std.GetBanker(std.BankerTypeRealmSend) + pkgAddr := std.GetOrigPkgAddr() + banker.SendCoins(pkgAddr, to, sent) + } + amountStr := sent.String() + swap := newSwap(sender, recipient, hashlock, timelock, amountStr, sendFn) + + counter++ + id := strconv.Itoa(counter) + swaps.Set(id, swap) + return counter, swap +} + +// NewCustomGRC20Swap creates a new atomic swap contract for grc20 tokens. +// It is not callable with `gnokey maketx call`, but can be imported by another contract or `gnokey maketx run`. +func NewCustomGRC20Swap(recipient std.Address, hashlock string, timelock time.Time, token *grc20.Token) (int, *Swap) { + sender := std.PrevRealm().Addr() + curAddr := std.CurrentRealm().Addr() + + allowance := token.Allowance(sender, curAddr) + require(allowance > 0, "no allowance") + + userTeller := token.CallerTeller() + err := userTeller.TransferFrom(sender, curAddr, allowance) + require(err == nil, "cannot retrieve tokens from allowance") + + amountStr := ufmt.Sprintf("%d%s", allowance, token.GetSymbol()) + sendFn := func(to std.Address) { + err := userTeller.Transfer(to, allowance) + require(err == nil, "cannot transfer tokens") + } + + swap := newSwap(sender, recipient, hashlock, timelock, amountStr, sendFn) + + counter++ + id := strconv.Itoa(counter) + swaps.Set(id, swap) + + return counter, swap +} + +// Claim loads a registered swap and tries to claim it. +func Claim(id int, secret string) { + swap := mustGet(id) + swap.Claim(secret) +} + +// Refund loads a registered swap and tries to refund it. +func Refund(id int) { + swap := mustGet(id) + swap.Refund() +} + +// Render returns a list of swaps (simplified) for the homepage, and swap details when specifying a swap ID. +func Render(path string) string { + if path == "" { // home + output := "" + size := swaps.Size() + max := 10 + swaps.ReverseIterateByOffset(size-max, max, func(key string, value interface{}) bool { + swap := value.(*Swap) + output += ufmt.Sprintf("- %s: %s -(%s)> %s - %s\n", + key, swap.sender, swap.amountStr, swap.recipient, swap.Status()) + return false + }) + return output + } else { // by id + swap, ok := swaps.Get(path) + if !ok { + return "404" + } + return swap.(*Swap).String() + } +} + +// require checks a condition and panics with a message if the condition is false. +func require(check bool, msg string) { + if !check { + panic(msg) + } +} + +// mustGet retrieves a swap by its id or panics. +func mustGet(id int) *Swap { + key := strconv.Itoa(id) + swap, ok := swaps.Get(key) + if !ok { + panic("unknown swap ID") + } + return swap.(*Swap) +} diff --git a/examples/gno.land/r/demo/atomicswap/atomicswap_test.gno b/examples/gno.land/r/demo/atomicswap/atomicswap_test.gno new file mode 100644 index 00000000000..0bcf6a1342d --- /dev/null +++ b/examples/gno.land/r/demo/atomicswap/atomicswap_test.gno @@ -0,0 +1,434 @@ +package atomicswap + +import ( + "crypto/sha256" + "encoding/hex" + "std" + "testing" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/r/demo/tests/test20" +) + +var testRun bool + +func TestNewCustomCoinSwap_Claim(t *testing.T) { + t.Skip("skipping due to bad support for unit-test driven banker") + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender1") + recipient := testutils.TestAddress("recipient1") + amount := std.Coins{{Denom: "ugnot", Amount: 1}} + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(1 * time.Hour) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + std.TestSetOrigSend(amount, nil) + id, swap := NewCustomCoinSwap(recipient, hashlockHex, timelock) + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wgc47h6lta047h6lta047h6l56jtjc +- recipient: g1wfjkx6tsd9jkuap3ta047h6lta047h6lkk20gv +- amount: 1ugnot +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test initial state + uassert.Equal(t, sender, swap.sender, "expected sender to match") + uassert.Equal(t, recipient, swap.recipient, "expected recipient to match") + uassert.Equal(t, swap.amountStr, amount.String(), "expected amount to match") + uassert.Equal(t, hashlockHex, swap.hashlock, "expected hashlock to match") + uassert.True(t, swap.timelock.Equal(timelock), "expected timelock to match") + uassert.False(t, swap.claimed, "expected claimed to be false") + uassert.False(t, swap.refunded, "expected refunded to be false") + + // Test claim + std.TestSetRealm(std.NewUserRealm(recipient)) + uassert.PanicsWithMessage(t, "invalid preimage", func() { swap.Claim("invalid") }) + swap.Claim("secret") + uassert.True(t, swap.claimed, "expected claimed to be true") + + // Test refund (should fail because already claimed) + uassert.PanicsWithMessage(t, "already claimed", swap.Refund) + uassert.PanicsWithMessage(t, "already claimed", func() { swap.Claim("secret") }) + + expected = `- status: claimed +- sender: g1wdjkuer9wgc47h6lta047h6lta047h6l56jtjc +- recipient: g1wfjkx6tsd9jkuap3ta047h6lta047h6lkk20gv +- amount: 1ugnot +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestNewCustomCoinSwap_Refund(t *testing.T) { + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender2") + recipient := testutils.TestAddress("recipient2") + amount := std.Coins{{Denom: "ugnot", Amount: 1}} + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(1 * time.Hour) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + std.TestSetOrigSend(amount, nil) + id, swap := NewCustomCoinSwap(recipient, hashlockHex, timelock) // Create a new swap + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wge97h6lta047h6lta047h6ltfacad +- recipient: g1wfjkx6tsd9jkuapjta047h6lta047h6lducc3v +- amount: 1ugnot +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test Refund + pkgAddr := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + std.TestSetOrigPkgAddr(pkgAddr) + std.TestIssueCoins(pkgAddr, std.Coins{{"ugnot", 100000000}}) + uassert.PanicsWithMessage(t, "timelock not expired", swap.Refund) + swap.timelock = time.Now().Add(-1 * time.Hour) // override timelock + swap.Refund() + uassert.True(t, swap.refunded, "expected refunded to be true") + expected = `- status: refunded +- sender: g1wdjkuer9wge97h6lta047h6lta047h6ltfacad +- recipient: g1wfjkx6tsd9jkuapjta047h6lta047h6lducc3v +- amount: 1ugnot +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-13T22:31:30Z +- remaining: 0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestNewCustomGRC20Swap_Claim(t *testing.T) { + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender3") + recipient := testutils.TestAddress("recipient3") + rlm := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(1 * time.Hour) + + test20.PrivateLedger.Mint(sender, 100_000) + test20.PrivateLedger.Approve(sender, rlm, 70_000) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + id, swap := NewCustomGRC20Swap(recipient, hashlockHex, timelock, test20.Token) + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wge47h6lta047h6lta047h6l5rk38l +- recipient: g1wfjkx6tsd9jkuapnta047h6lta047h6ly6k4pv +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test initial state + uassert.Equal(t, sender, swap.sender, "expected sender to match") + uassert.Equal(t, recipient, swap.recipient, "expected recipient to match") + bal := test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(70_000)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + // uassert.Equal(t, swap.amountStr, amount.String(), "expected amount to match") + uassert.Equal(t, hashlockHex, swap.hashlock, "expected hashlock to match") + uassert.True(t, swap.timelock.Equal(timelock), "expected timelock to match") + uassert.False(t, swap.claimed, "expected claimed to be false") + uassert.False(t, swap.refunded, "expected refunded to be false") + + // Test claim + std.TestSetRealm(std.NewUserRealm(recipient)) + uassert.PanicsWithMessage(t, "invalid preimage", func() { swap.Claim("invalid") }) + swap.Claim("secret") + uassert.True(t, swap.claimed, "expected claimed to be true") + + bal = test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(0)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(70_000)) + + // Test refund (should fail because already claimed) + uassert.PanicsWithMessage(t, "already claimed", swap.Refund) + uassert.PanicsWithMessage(t, "already claimed", func() { swap.Claim("secret") }) + + expected = `- status: claimed +- sender: g1wdjkuer9wge47h6lta047h6lta047h6l5rk38l +- recipient: g1wfjkx6tsd9jkuapnta047h6lta047h6ly6k4pv +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestNewCustomGRC20Swap_Refund(t *testing.T) { + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender5") + recipient := testutils.TestAddress("recipient5") + rlm := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(1 * time.Hour) + + test20.PrivateLedger.Mint(sender, 100_000) + test20.PrivateLedger.Approve(sender, rlm, 70_000) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + id, swap := NewCustomGRC20Swap(recipient, hashlockHex, timelock, test20.Token) + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wg647h6lta047h6lta047h6l5p6k3k +- recipient: g1wfjkx6tsd9jkuap4ta047h6lta047h6lmwmj6v +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-14T00:31:30Z +- remaining: 1h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test initial state + uassert.Equal(t, sender, swap.sender, "expected sender to match") + uassert.Equal(t, recipient, swap.recipient, "expected recipient to match") + bal := test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(70_000)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + // Test Refund + pkgAddr := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + std.TestSetOrigPkgAddr(pkgAddr) + std.TestIssueCoins(pkgAddr, std.Coins{{"ugnot", 100000000}}) + uassert.PanicsWithMessage(t, "timelock not expired", swap.Refund) + swap.timelock = time.Now().Add(-1 * time.Hour) // override timelock + swap.Refund() + uassert.True(t, swap.refunded, "expected refunded to be true") + + bal = test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(100_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(0)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + expected = `- status: refunded +- sender: g1wdjkuer9wg647h6lta047h6lta047h6l5p6k3k +- recipient: g1wfjkx6tsd9jkuap4ta047h6lta047h6lmwmj6v +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-13T22:31:30Z +- remaining: 0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestNewGRC20Swap_Claim(t *testing.T) { + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender4") + recipient := testutils.TestAddress("recipient4") + rlm := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(defaultTimelockDuration) + + test20.PrivateLedger.Mint(sender, 100_000) + test20.PrivateLedger.Approve(sender, rlm, 70_000) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + id, swap := NewGRC20Swap(recipient, hashlockHex, "gno.land/r/demo/tests/test20") + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wg697h6lta047h6lta047h6ltt3lty +- recipient: g1wfjkx6tsd9jkuap5ta047h6lta047h6ljg4l2v +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-20T23:31:30Z +- remaining: 168h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test initial state + uassert.Equal(t, sender, swap.sender, "expected sender to match") + uassert.Equal(t, recipient, swap.recipient, "expected recipient to match") + bal := test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(70_000)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + // uassert.Equal(t, swap.amountStr, amount.String(), "expected amount to match") + uassert.Equal(t, hashlockHex, swap.hashlock, "expected hashlock to match") + uassert.True(t, swap.timelock.Equal(timelock), "expected timelock to match") + uassert.False(t, swap.claimed, "expected claimed to be false") + uassert.False(t, swap.refunded, "expected refunded to be false") + + // Test claim + std.TestSetRealm(std.NewUserRealm(recipient)) + uassert.PanicsWithMessage(t, "invalid preimage", func() { swap.Claim("invalid") }) + swap.Claim("secret") + uassert.True(t, swap.claimed, "expected claimed to be true") + + bal = test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(0)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(70_000)) + + // Test refund (should fail because already claimed) + uassert.PanicsWithMessage(t, "already claimed", swap.Refund) + uassert.PanicsWithMessage(t, "already claimed", func() { swap.Claim("secret") }) + + expected = `- status: claimed +- sender: g1wdjkuer9wg697h6lta047h6lta047h6ltt3lty +- recipient: g1wfjkx6tsd9jkuap5ta047h6lta047h6ljg4l2v +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-20T23:31:30Z +- remaining: 168h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestNewGRC20Swap_Refund(t *testing.T) { + defer resetTestState() + + // Setup + sender := testutils.TestAddress("sender6") + recipient := testutils.TestAddress("recipient6") + rlm := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(defaultTimelockDuration) + + test20.PrivateLedger.Mint(sender, 100_000) + test20.PrivateLedger.Approve(sender, rlm, 70_000) + + // Create a new swap + std.TestSetRealm(std.NewUserRealm(sender)) + id, swap := NewGRC20Swap(recipient, hashlockHex, "gno.land/r/demo/tests/test20") + uassert.Equal(t, 1, id) + + expected := `- status: active +- sender: g1wdjkuer9wgm97h6lta047h6lta047h6ltj497r +- recipient: g1wfjkx6tsd9jkuapkta047h6lta047h6lqyf9rv +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-20T23:31:30Z +- remaining: 168h0m0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) + + // Test initial state + uassert.Equal(t, sender, swap.sender, "expected sender to match") + uassert.Equal(t, recipient, swap.recipient, "expected recipient to match") + bal := test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(30_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(70_000)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + // Test Refund + pkgAddr := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + std.TestSetOrigPkgAddr(pkgAddr) + std.TestIssueCoins(pkgAddr, std.Coins{{"ugnot", 100000000}}) + uassert.PanicsWithMessage(t, "timelock not expired", swap.Refund) + swap.timelock = time.Now().Add(-1 * time.Hour) // override timelock + swap.Refund() + uassert.True(t, swap.refunded, "expected refunded to be true") + + bal = test20.Token.BalanceOf(sender) + uassert.Equal(t, bal, uint64(100_000)) + bal = test20.Token.BalanceOf(rlm) + uassert.Equal(t, bal, uint64(0)) + bal = test20.Token.BalanceOf(recipient) + uassert.Equal(t, bal, uint64(0)) + + expected = `- status: refunded +- sender: g1wdjkuer9wgm97h6lta047h6lta047h6ltj497r +- recipient: g1wfjkx6tsd9jkuapkta047h6lta047h6lqyf9rv +- amount: 70000TST +- hashlock: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b +- timelock: 2009-02-13T22:31:30Z +- remaining: 0s` + uassert.Equal(t, expected, swap.String()) + uassert.Equal(t, expected, Render("1")) +} + +func TestRender(t *testing.T) { + defer resetTestState() + + // Setup + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + charly := testutils.TestAddress("charly") + rlm := std.DerivePkgAddr("gno.land/r/demo/atomicswap") + hashlock := sha256.Sum256([]byte("secret")) + hashlockHex := hex.EncodeToString(hashlock[:]) + timelock := time.Now().Add(1 * time.Hour) + + test20.PrivateLedger.Mint(alice, 100_000) + std.TestSetRealm(std.NewUserRealm(alice)) + + userTeller := test20.Token.CallerTeller() + userTeller.Approve(rlm, 10_000) + _, bobSwap := NewCustomGRC20Swap(bob, hashlockHex, timelock, test20.Token) + + userTeller.Approve(rlm, 20_000) + _, _ = NewCustomGRC20Swap(charly, hashlockHex, timelock, test20.Token) + + std.TestSetRealm(std.NewUserRealm(bob)) + bobSwap.Claim("secret") + + expected := `- 2: g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh -(20000TST)> g1vd5xzunv09047h6lta047h6lta047h6lhsyveh - active +- 1: g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh -(10000TST)> g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu - claimed +` + uassert.Equal(t, expected, Render("")) +} + +func resetTestState() { + swaps = avl.Tree{} + counter = 0 +} diff --git a/examples/gno.land/r/demo/atomicswap/gno.mod b/examples/gno.land/r/demo/atomicswap/gno.mod new file mode 100644 index 00000000000..1d6580c51e8 --- /dev/null +++ b/examples/gno.land/r/demo/atomicswap/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/atomicswap diff --git a/examples/gno.land/r/demo/atomicswap/swap.gno b/examples/gno.land/r/demo/atomicswap/swap.gno new file mode 100644 index 00000000000..da40805221e --- /dev/null +++ b/examples/gno.land/r/demo/atomicswap/swap.gno @@ -0,0 +1,98 @@ +package atomicswap + +import ( + "crypto/sha256" + "encoding/hex" + "std" + "time" + + "gno.land/p/demo/ufmt" +) + +// Swap represents an atomic swap contract. +type Swap struct { + sender std.Address + recipient std.Address + hashlock string + timelock time.Time + claimed bool + refunded bool + amountStr string + sendFn func(to std.Address) +} + +func newSwap( + sender std.Address, + recipient std.Address, + hashlock string, + timelock time.Time, + amountStr string, + sendFn func(std.Address), +) *Swap { + require(time.Now().Before(timelock), "timelock must be in the future") + require(hashlock != "", "hashlock must not be empty") + return &Swap{ + recipient: recipient, + sender: sender, + hashlock: hashlock, + timelock: timelock, + claimed: false, + refunded: false, + sendFn: sendFn, + amountStr: amountStr, + } +} + +// Claim allows the recipient to claim the funds if they provide the correct preimage. +func (s *Swap) Claim(preimage string) { + require(!s.claimed, "already claimed") + require(!s.refunded, "already refunded") + require(std.PrevRealm().Addr() == s.recipient, "unauthorized") + + hashlock := sha256.Sum256([]byte(preimage)) + hashlockHex := hex.EncodeToString(hashlock[:]) + require(hashlockHex == s.hashlock, "invalid preimage") + + s.claimed = true + s.sendFn(s.recipient) +} + +// Refund allows the sender to refund the funds after the timelock has expired. +func (s *Swap) Refund() { + require(!s.claimed, "already claimed") + require(!s.refunded, "already refunded") + require(std.PrevRealm().Addr() == s.sender, "unauthorized") + require(time.Now().After(s.timelock), "timelock not expired") + + s.refunded = true + s.sendFn(s.sender) +} + +func (s Swap) Status() string { + switch { + case s.refunded: + return "refunded" + case s.claimed: + return "claimed" + case s.TimeRemaining() < 0: + return "expired" + default: + return "active" + } +} + +func (s Swap) TimeRemaining() time.Duration { + remaining := time.Until(s.timelock) + if remaining < 0 { + return 0 + } + return remaining +} + +// String returns the current state of the swap. +func (s Swap) String() string { + return ufmt.Sprintf( + "- status: %s\n- sender: %s\n- recipient: %s\n- amount: %s\n- hashlock: %s\n- timelock: %s\n- remaining: %s", + s.Status(), s.sender, s.recipient, s.amountStr, s.hashlock, s.timelock.Format(time.RFC3339), s.TimeRemaining().String(), + ) +} diff --git a/gno.land/pkg/integration/testdata/atomicswap.txtar b/gno.land/pkg/integration/testdata/atomicswap.txtar new file mode 100644 index 00000000000..6d0f89a6dc8 --- /dev/null +++ b/gno.land/pkg/integration/testdata/atomicswap.txtar @@ -0,0 +1,41 @@ +loadpkg gno.land/r/demo/atomicswap +# XXX: would be nice to specify an artibrary initial balance as an "adduser" argument. cc @gfanton +adduser test2 +adduser test3 + +gnoland start + +gnokey maketx send -send 1000000000ugnot -to $test2_user_addr -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 +gnokey maketx send -send 1000000000ugnot -to $test3_user_addr -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 + +gnokey query auth/accounts/$test2_user_addr +stdout 'coins.*:.*1010000000ugnot' + +gnokey query auth/accounts/$test3_user_addr +stdout 'coins.*:.*1010000000ugnot' + +# To generate the hash for "secret", use a hashing tool or library of your choice. For example: +# In Unix-based systems, you can use: +# echo -n "secret" | sha256sum +# This will produce a hashlock string like "2bb808d537b1da3e38bd30361aa85586dbbeacdd7126fef6a25ef97b5f27a25b". +# Replace the hashlock argument in the command below with the generated hash. +gnokey maketx call -pkgpath gno.land/r/demo/atomicswap -func NewCoinSwap -gas-fee 1000000ugnot -send 12345ugnot -gas-wanted 10000000 -args $test3_user_addr -args '2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b' -broadcast -chainid=tendermint_test test2 +stdout '(1 int)' +stdout ".*$test2_user_addr.*$test3_user_addr.*12345ugnot.*" +stdout 'OK!' + +gnokey maketx call -pkgpath gno.land/r/demo/atomicswap -func Render -gas-fee 1000000ugnot -gas-wanted 10000000 -args '' -broadcast -chainid=tendermint_test test2 +stdout 'OK!' + +gnokey query auth/accounts/$test2_user_addr +stdout 'coins.*:.*1007987655ugnot' +gnokey query auth/accounts/$test3_user_addr +stdout 'coins.*:.*1010000000ugnot' + +gnokey maketx call -pkgpath gno.land/r/demo/atomicswap -func Claim -gas-fee 1ugnot -gas-wanted 10000000 -args '1' -args 'secret' -broadcast -chainid=tendermint_test test3 +stdout 'OK!' + +gnokey query auth/accounts/$test2_user_addr +stdout 'coins.*:.*1007987655ugnot' +gnokey query auth/accounts/$test3_user_addr +stdout 'coins.*:.*1010012344ugnot'