atomicswap.gno
5.90 Kb · 184 lines
1// Package atomicswap implements a hash time-locked contract (HTLC) for atomic swaps
2// between native coins (ugnot) or GRC20 tokens.
3//
4// An atomic swap allows two parties to exchange assets in a trustless way, where
5// either both transfers happen or neither does. The process works as follows:
6//
7// 1. Alice wants to swap with Bob. She generates a secret and creates a swap with
8// Bob's address and the hash of the secret (hashlock).
9//
10// 2. Bob can claim the assets by providing the correct secret before the timelock expires.
11// The secret proves Bob knows the preimage of the hashlock.
12//
13// 3. If Bob doesn't claim in time, Alice can refund the assets back to herself.
14//
15// Example usage for native coins:
16//
17// // Alice creates a swap with 1000ugnot for Bob
18// secret := "mysecret"
19// hashlock := hex.EncodeToString(sha256.Sum256([]byte(secret)))
20// id, _ := atomicswap.NewCoinSwap(bobAddr, hashlock) // -send 1000ugnot
21//
22// // Bob claims the swap by providing the secret
23// atomicswap.Claim(id, "mysecret")
24//
25// Example usage for GRC20 tokens:
26//
27// // Alice approves the swap contract to spend her tokens
28// token.Approve(swapAddr, 1000)
29//
30// // Alice creates a swap with 1000 tokens for Bob
31// id, _ := atomicswap.NewGRC20Swap(bobAddr, hashlock, "gno.land/r/demo/token")
32//
33// // Bob claims the swap by providing the secret
34// atomicswap.Claim(id, "mysecret")
35//
36// If Bob doesn't claim in time (default 1 week), Alice can refund:
37//
38// atomicswap.Refund(id)
39package atomicswap
40
41import (
42 "chain/banker"
43 "chain/runtime"
44 "strconv"
45 "time"
46
47 "gno.land/p/demo/tokens/grc20"
48 "gno.land/p/nt/avl/v0"
49 "gno.land/p/nt/ufmt/v0"
50 "gno.land/r/demo/defi/grc20reg"
51)
52
53const defaultTimelockDuration = 7 * 24 * time.Hour // 1w
54
55var (
56 swaps avl.Tree // id -> *Swap
57 counter int
58)
59
60// NewCoinSwap creates a new atomic swap contract for native coins.
61// It uses a default timelock duration.
62func NewCoinSwap(cur realm, recipient address, hashlock string) (int, *Swap) {
63 timelock := time.Now().Add(defaultTimelockDuration)
64 return NewCustomCoinSwap(cur, recipient, hashlock, timelock)
65}
66
67// NewGRC20Swap creates a new atomic swap contract for grc20 tokens.
68// It uses gno.land/r/demo/defi/grc20reg to lookup for a registered token.
69func NewGRC20Swap(cur realm, recipient address, hashlock string, tokenRegistryKey string) (int, *Swap) {
70 timelock := time.Now().Add(defaultTimelockDuration)
71 token := grc20reg.MustGet(tokenRegistryKey)
72 return NewCustomGRC20Swap(cur, recipient, hashlock, timelock, token)
73}
74
75// NewCoinSwapWithTimelock creates a new atomic swap contract for native coin.
76// It allows specifying a custom timelock duration.
77//
78// Only direct user-call (maketx call) is accepted: banker.OriginSend()
79// describes a real receipt at this realm only when the caller is a pure
80// EOA. Intermediate code realms or `maketx run` ephemeral realms can
81// attach -send to the tx but spend the envelope elsewhere, leaving
82// OriginSend() describing a phantom payment that would let the swap
83// drain the realm's pre-existing balance on Claim.
84func NewCustomCoinSwap(cur realm, recipient address, hashlock string, timelock time.Time) (int, *Swap) {
85 if !runtime.PreviousRealm().IsUserCall() {
86 panic("only user-call (maketx call) accepted")
87 }
88 sender := runtime.PreviousRealm().Address()
89 sent := banker.OriginSend()
90 require(len(sent) != 0, "at least one coin needs to be sent")
91
92 // Create the swap
93 sendFn := func(cur realm, to address) {
94 banker_ := banker.NewBanker(banker.BankerTypeRealmSend)
95 pkgAddr := runtime.CurrentRealm().Address()
96 banker_.SendCoins(pkgAddr, to, sent)
97 }
98 amountStr := sent.String()
99 swap := newSwap(sender, recipient, hashlock, timelock, amountStr, sendFn)
100
101 counter++
102 id := strconv.Itoa(counter)
103 swaps.Set(id, swap)
104 return counter, swap
105}
106
107// NewCustomGRC20Swap creates a new atomic swap contract for grc20 tokens.
108// It is not callable with `gnokey maketx call`, but can be imported by another contract or `gnokey maketx run`.
109func NewCustomGRC20Swap(cur realm, recipient address, hashlock string, timelock time.Time, token *grc20.Token) (int, *Swap) {
110 sender := runtime.PreviousRealm().Address()
111 curAddr := runtime.CurrentRealm().Address()
112
113 allowance := token.Allowance(sender, curAddr)
114 require(allowance > 0, "no allowance")
115
116 userTeller := token.RealmTeller()
117 err := userTeller.TransferFrom(sender, curAddr, allowance)
118 require(err == nil, "cannot retrieve tokens from allowance")
119
120 amountStr := ufmt.Sprintf("%d%s", allowance, token.GetSymbol())
121 sendFn := func(cur realm, to address) {
122 err := userTeller.Transfer(to, allowance)
123 require(err == nil, "cannot transfer tokens")
124 }
125
126 swap := newSwap(sender, recipient, hashlock, timelock, amountStr, sendFn)
127
128 counter++
129 id := strconv.Itoa(counter)
130 swaps.Set(id, swap)
131
132 return counter, swap
133}
134
135// Claim loads a registered swap and tries to claim it.
136func Claim(cur realm, id int, secret string) {
137 swap := mustGet(id)
138 swap.Claim(secret)
139}
140
141// Refund loads a registered swap and tries to refund it.
142func Refund(cur realm, id int) {
143 swap := mustGet(id)
144 swap.Refund()
145}
146
147// Render returns a list of swaps (simplified) for the homepage, and swap details when specifying a swap ID.
148func Render(path string) string {
149 if path == "" { // home
150 output := ""
151 size := swaps.Size()
152 max := 10
153 swaps.ReverseIterateByOffset(size-max, max, func(key string, value any) bool {
154 swap := value.(*Swap)
155 output += ufmt.Sprintf("- %s: %s -(%s)> %s - %s\n",
156 key, swap.sender, swap.amountStr, swap.recipient, swap.Status())
157 return false
158 })
159 return output
160 } else { // by id
161 swap, ok := swaps.Get(path)
162 if !ok {
163 return "404"
164 }
165 return swap.(*Swap).String()
166 }
167}
168
169// require checks a condition and panics with a message if the condition is false.
170func require(check bool, msg string) {
171 if !check {
172 panic(msg)
173 }
174}
175
176// mustGet retrieves a swap by its id or panics.
177func mustGet(id int) *Swap {
178 key := strconv.Itoa(id)
179 swap, ok := swaps.Get(key)
180 if !ok {
181 panic("unknown swap ID")
182 }
183 return swap.(*Swap)
184}