├── .tool-versions ├── .golangci.yaml ├── tonconnect-manifest.json ├── README.md ├── go.mod ├── LICENSE ├── wallets.go ├── messages.go ├── go.sum ├── sign.go ├── examples └── basic │ └── main.go ├── link.go ├── connect.go ├── send.go └── session.go /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.21 2 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 3m 3 | -------------------------------------------------------------------------------- /tonconnect-manifest.json: -------------------------------------------------------------------------------- 1 | {"url":"https://cameo.engineering","name":"Cameo","iconUrl":"https://i.imgur.com/TNYgSLU.png"} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TON Connect 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/cameo-engineering/tonconnect)](https://goreportcard.com/report/github.com/cameo-engineering/tonconnect) 4 | 5 | ## Getting Started 6 | 7 | ```bash 8 | go get github.com/cameo-engineering/tonconnect 9 | ``` 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cameo-engineering/tonconnect 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/kevinburke/nacl v0.0.0-20210405173606-cd9060f5f776 7 | github.com/tmaxmax/go-sse v0.8.0 8 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 9 | golang.org/x/sync v0.7.0 10 | ) 11 | 12 | require ( 13 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 14 | golang.org/x/crypto v0.17.0 // indirect 15 | golang.org/x/sys v0.15.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2023 Cameo Engineering LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /wallets.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "slices" 5 | ) 6 | 7 | type Wallet struct { 8 | Name string `json:"name"` 9 | UniversalURL string `json:"universal_url"` 10 | BridgeURL string `json:"bridge_url"` 11 | } 12 | 13 | var Wallets = map[string]Wallet{ 14 | "telegram-wallet": { 15 | Name: "Wallet", 16 | UniversalURL: "https://t.me/wallet/start?startapp=", 17 | BridgeURL: "https://bridge.ton.space/bridge", 18 | }, 19 | "tonkeeper": { 20 | Name: "Tonkeeper", 21 | UniversalURL: "https://app.tonkeeper.com/ton-connect", 22 | BridgeURL: "https://bridge.tonapi.io/bridge", 23 | }, 24 | "mytonwallet": { 25 | Name: "MyTonWallet", 26 | UniversalURL: "https://connect.mytonwallet.org/", 27 | BridgeURL: "https://tonconnectbridge.mytonwallet.org/bridge", 28 | }, 29 | "tonhub": { 30 | Name: "Tonhub", 31 | UniversalURL: "https://tonhub.com/ton-connect", 32 | BridgeURL: "https://connect.tonhubapi.com/tonconnect", 33 | }, 34 | "dewallet": { 35 | Name: "DeWallet", 36 | UniversalURL: "https://t.me/dewallet?attach=wallet", 37 | BridgeURL: "https://sse-bridge.delab.team/bridge", 38 | }, 39 | "bitgetTonWallet": { 40 | Name: "Bitget Wallet", 41 | UniversalURL: "https://bkcode.vip/ton-connect", 42 | BridgeURL: "https://bridge.tonapi.io/bridge", 43 | }, 44 | "safepalwallet": { 45 | Name: "SafePal", 46 | UniversalURL: "https://link.safepal.io/ton-connect", 47 | BridgeURL: "https://ton-bridge.safepal.com/tonbridge/v1/bridge", 48 | }, 49 | } 50 | 51 | func getBridgeURLs(wallets ...Wallet) []string { 52 | var bridges []string 53 | for _, w := range wallets { 54 | bridges = append(bridges, w.BridgeURL) 55 | } 56 | 57 | slices.Sort(bridges) 58 | return slices.Compact(bridges) 59 | } 60 | -------------------------------------------------------------------------------- /messages.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/kevinburke/nacl" 7 | ) 8 | 9 | type bridgeMessage struct { 10 | BrdigeURL string 11 | From nacl.Key 12 | Message walletMessage 13 | } 14 | 15 | type walletMessage struct { 16 | ID json.Number `json:"id,omitempty"` 17 | Event string `json:"event,omitempty"` 18 | Type string `json:"type,omitempty"` 19 | Result any `json:"result,omitempty"` 20 | Payload payload `json:"payload,omitempty"` 21 | Error *struct { 22 | Code uint64 `json:"code"` 23 | Message string `json:"message"` 24 | } `json:"error,omitempty"` 25 | } 26 | 27 | type payload struct { 28 | Code uint64 `json:"code,omitempty"` 29 | Message string `json:"message,omitempty"` 30 | Device deviceInfo `json:"device,omitempty"` 31 | Items []connectItemReply `json:"items,omitempty"` 32 | } 33 | 34 | type deviceInfo struct { 35 | Platform string `json:"platform"` 36 | AppName string `json:"appName"` 37 | AppVersion string `json:"appVersion"` 38 | MaxProtocolVersion uint64 `json:"maxProtocolVersion"` 39 | Features []any `json:"features"` 40 | } 41 | 42 | type feature struct { 43 | Name string `json:"name"` 44 | MaxMessages uint64 `json:"maxMessages,omitempty"` 45 | } 46 | 47 | type connectItemReply struct { 48 | Name string `json:"name"` 49 | Address string `json:"address,omitempty"` 50 | Network int64 `json:"network,string,omitempty"` 51 | PublicKey string `json:"publicKey,omitempty"` 52 | WalletStateInit []byte `json:"walletStateInit,omitempty"` 53 | Proof proof `json:"proof,omitempty"` 54 | Error *struct { 55 | Code uint64 `json:"code"` 56 | Message string `json:"message"` 57 | } `json:"error,omitempty"` 58 | } 59 | 60 | type proof struct { 61 | Timestamp uint64 `json:"timestamp"` 62 | Domain struct { 63 | LengthBytes uint64 `json:"lengthBytes"` 64 | Value string `json:"value"` 65 | } `json:"domain"` 66 | Signature []byte `json:"signature"` 67 | Payload string `json:"payload"` 68 | } 69 | 70 | type signDataResult struct { 71 | Signature []byte `json:"signature"` 72 | Timestamp uint64 `json:"timestamp"` 73 | } 74 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 2 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/kevinburke/nacl v0.0.0-20210405173606-cd9060f5f776 h1:W8T7zJRO9imecUZySwPkuXHosjp2MloqAY1eSAEEOIo= 6 | github.com/kevinburke/nacl v0.0.0-20210405173606-cd9060f5f776/go.mod h1:VUp2yfq+wAk8hMl3NNN34fXjzUD9xMpGvUL8eSJz9Ns= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 10 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/tmaxmax/go-sse v0.7.0 h1:5cMmA9v1YxxnB8D3h2j3YRkLIVLzt4IAwRLOesQY884= 12 | github.com/tmaxmax/go-sse v0.7.0/go.mod h1:86RhFezBCIdDFddwc+pbcqb5GKAsfR7d86Oxb9xLHMg= 13 | github.com/tmaxmax/go-sse v0.8.0 h1:pPpTgyyi1r7vG2o6icebnpGEh3ebcnBXqDWkb7aTofs= 14 | github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= 15 | golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= 16 | golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 17 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 18 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 19 | golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE= 20 | golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 21 | golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4= 22 | golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 23 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= 24 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 25 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 26 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 27 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 28 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 29 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 30 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /sign.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "golang.org/x/sync/errgroup" 9 | ) 10 | 11 | type signDataRequest struct { 12 | ID string `json:"id"` 13 | Method string `json:"method"` 14 | Params []SignData `json:"params"` 15 | } 16 | 17 | type SignData struct { 18 | SchemaCRC uint32 `json:"schema_crc"` 19 | Cell []byte `json:"cell"` 20 | PublicKey string `json:"publicKey,omitempty"` 21 | } 22 | 23 | type signDataResponse struct { 24 | ID string `json:"id"` 25 | Result signDataResult `json:"result,omitempty"` 26 | Error *struct { 27 | Code uint64 `json:"code"` 28 | Message string `json:"message"` 29 | } `json:"error,omitempty"` 30 | } 31 | 32 | type signDataOpt = func(*SignData) 33 | 34 | func (s *Session) SignData(ctx context.Context, data SignData, options ...bridgeMessageOption) (*signDataResult, error) { 35 | ctx, cancel := context.WithCancel(ctx) 36 | defer cancel() 37 | g, ctx := errgroup.WithContext(ctx) 38 | msgs := make(chan bridgeMessage) 39 | 40 | id := s.LastRequestID + 1 41 | g.Go(func() error { 42 | req := signDataRequest{ 43 | ID: strconv.FormatUint(id, 10), 44 | Method: "signData", 45 | Params: []SignData{data}, 46 | } 47 | 48 | err := s.sendMessage(ctx, req, "signData", options...) 49 | if err == nil { 50 | s.LastRequestID = id 51 | } 52 | 53 | return err 54 | }) 55 | 56 | var res signDataResult 57 | g.Go(func() error { 58 | for { 59 | select { 60 | case <-ctx.Done(): 61 | return ctx.Err() 62 | case msg := <-msgs: 63 | msgID, err := msg.Message.ID.Int64() 64 | if err == nil { 65 | s.LastRequestID = uint64(msgID) 66 | } 67 | if int64(id) == msgID { 68 | if msg.Message.Error != nil { 69 | if msg.Message.Error.Message != "" { 70 | return fmt.Errorf("tonconnect: %s", msg.Message.Error.Message) 71 | } 72 | 73 | switch msg.Message.Error.Code { 74 | case 1: 75 | return fmt.Errorf("tonconnect: bad request") 76 | case 100: 77 | return fmt.Errorf("tonconnect: unknown app") 78 | case 300: 79 | return fmt.Errorf("tonconnect: user declined the signature request") 80 | case 400: 81 | return fmt.Errorf("tonconnect: %q method is not supported", "signData") 82 | default: 83 | return fmt.Errorf("tonconnect: unknown data sign error") 84 | } 85 | } 86 | 87 | cancel() 88 | 89 | var ok bool 90 | res, ok = msg.Message.Result.(signDataResult) 91 | if !ok { 92 | return fmt.Errorf("tonconnect: data sign result expected to be of type %q", "signDataResult") 93 | } 94 | 95 | return nil 96 | } 97 | } 98 | } 99 | }) 100 | 101 | g.Go(func() error { 102 | return s.connectToBridge(ctx, s.BridgeURL, msgs) 103 | }) 104 | 105 | err := g.Wait() 106 | 107 | return &res, err 108 | } 109 | 110 | func NewSignDataRequest(schemaCRC uint32, cell []byte, options ...signDataOpt) (*SignData, error) { 111 | data := &SignData{SchemaCRC: schemaCRC, Cell: cell} 112 | for _, opt := range options { 113 | opt(data) 114 | } 115 | 116 | return data, nil 117 | } 118 | 119 | func WithPublicKey(pubkey string) signDataOpt { 120 | return func(data *SignData) { 121 | data.PublicKey = pubkey 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base32" 7 | "encoding/json" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io/fs" 12 | "log" 13 | "os" 14 | "time" 15 | 16 | "github.com/cameo-engineering/tonconnect" 17 | "golang.org/x/exp/maps" 18 | ) 19 | 20 | func readSession(filename string) (*tonconnect.Session, error) { 21 | file, err := os.Open(filename) 22 | if err != nil { 23 | if errors.Is(err, fs.ErrNotExist) { 24 | return nil, nil 25 | } 26 | return nil, err 27 | } 28 | defer file.Close() 29 | 30 | var session tonconnect.Session 31 | if err := json.NewDecoder(file).Decode(&session); err != nil { 32 | return nil, err 33 | } 34 | 35 | return &session, nil 36 | } 37 | 38 | func saveSession(filename string, session *tonconnect.Session) error { 39 | file, err := os.Create(filename) 40 | if err != nil { 41 | return err 42 | } 43 | defer file.Close() 44 | 45 | return json.NewEncoder(file).Encode(session) 46 | } 47 | 48 | func main() { 49 | var sName string 50 | flag.StringVar(&sName, "session", "./session.json", "") 51 | 52 | s, err := readSession(sName) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | 57 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 58 | defer cancel() 59 | 60 | if s == nil { 61 | s, err = tonconnect.NewSession() 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | 66 | data := make([]byte, 32) 67 | _, err = rand.Read(data) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | connreq, err := tonconnect.NewConnectRequest( 73 | "https://raw.githubusercontent.com/cameo-engineering/tonconnect/master/tonconnect-manifest.json", 74 | tonconnect.WithProofRequest(base32.StdEncoding.EncodeToString(data)), 75 | ) 76 | if err != nil { 77 | log.Fatal(err) 78 | } 79 | 80 | deeplink, err := s.GenerateDeeplink(*connreq, tonconnect.WithBackReturnStrategy()) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | fmt.Printf("Deeplink: %s\n\n", deeplink) 85 | 86 | wrapped := tonconnect.WrapDeeplink(deeplink) 87 | fmt.Printf("Wrapped deeplink: %s\n\n", wrapped) 88 | 89 | for _, wallet := range tonconnect.Wallets { 90 | link, err := s.GenerateUniversalLink(wallet, *connreq) 91 | fmt.Printf("%s: %s\n\n", wallet.Name, link) 92 | if err != nil { 93 | log.Fatal(err) 94 | } 95 | } 96 | 97 | res, err := s.Connect(ctx, (maps.Values(tonconnect.Wallets))...) 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | 102 | var addr string 103 | network := "mainnet" 104 | for _, item := range res.Items { 105 | if item.Name == "ton_addr" { 106 | addr = item.Address 107 | if item.Network == -3 { 108 | network = "testnet" 109 | } 110 | } 111 | } 112 | fmt.Printf( 113 | "%s %s for %s is connected to %s with %s address\n\n", 114 | res.Device.AppName, 115 | res.Device.AppVersion, 116 | res.Device.Platform, 117 | network, 118 | addr, 119 | ) 120 | } 121 | 122 | defer func() { 123 | if err := saveSession(sName, s); err != nil { 124 | log.Fatal(err) 125 | } 126 | }() 127 | 128 | msg, err := tonconnect.NewMessage("UQCJ1-sj6HahSz3fXaT50lSfpZVQDoJaFVahpJLpJ5SXqQ5Y", "100000000") 129 | if err != nil { 130 | log.Fatal(err) 131 | } 132 | tx, err := tonconnect.NewTransaction( 133 | tonconnect.WithTimeout(10*time.Minute), 134 | tonconnect.WithMessage(*msg), 135 | ) 136 | if err != nil { 137 | log.Fatal(err) 138 | } 139 | boc, err := s.SendTransaction(ctx, *tx) 140 | if err != nil { 141 | fmt.Println(err) 142 | } else { 143 | fmt.Printf("Bag of Cells: %x", boc) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /link.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | type ConnectRequest struct { 12 | ManifestURL string `json:"manifestUrl"` 13 | Items []ConnectItem `json:"items"` 14 | } 15 | 16 | type ConnectItem struct { 17 | Name string `json:"name"` 18 | Payload string `json:"payload,omitempty"` 19 | } 20 | 21 | type connReqOpt = func(*ConnectRequest) 22 | 23 | type linkOptions struct { 24 | ReturnStrategy string 25 | } 26 | 27 | type linkOption = func(*linkOptions) 28 | 29 | const ( 30 | wrapURL string = "https://ton-connect.github.io/open-tc" 31 | ) 32 | 33 | func NewConnectRequest(manifestURL string, options ...connReqOpt) (*ConnectRequest, error) { 34 | connReq := &ConnectRequest{ 35 | ManifestURL: manifestURL, 36 | } 37 | connReq.Items = append(connReq.Items, ConnectItem{Name: "ton_addr"}) 38 | 39 | for _, opt := range options { 40 | opt(connReq) 41 | } 42 | 43 | return connReq, nil 44 | } 45 | 46 | func WithProofRequest(payload string) connReqOpt { 47 | return func(connReq *ConnectRequest) { 48 | connReq.Items = append(connReq.Items, ConnectItem{Name: "ton_proof", Payload: payload}) 49 | } 50 | } 51 | 52 | func (s *Session) GenerateUniversalLink(wallet Wallet, connreq ConnectRequest, options ...linkOption) (string, error) { 53 | opts := &linkOptions{ReturnStrategy: "back"} 54 | for _, opt := range options { 55 | opt(opts) 56 | } 57 | 58 | u, err := url.Parse(wallet.UniversalURL) 59 | if err != nil { 60 | return "", fmt.Errorf("tonconnect: failed to parse %q wallet universal URL: %w", wallet.Name, err) 61 | } 62 | 63 | q := u.Query() 64 | q.Set("v", "2") 65 | q.Set("id", hex.EncodeToString(s.ID[:])) 66 | 67 | data, err := json.Marshal(connreq) 68 | if err != nil { 69 | return "", fmt.Errorf("tonconnect: failed to marshal connection request: %w", err) 70 | } 71 | q.Set("r", string(data)) 72 | 73 | q.Set("ret", opts.ReturnStrategy) 74 | 75 | rawQuery := q.Encode() 76 | if isTelegramURL(u) { 77 | clear(q) 78 | q.Set("startapp", "tonconnect-"+encodeTelegramURLParams(rawQuery)) 79 | rawQuery = q.Encode() 80 | } 81 | u.RawQuery = rawQuery 82 | 83 | link := u.String() 84 | // HACK: 85 | if u.Scheme == "tc" { 86 | link = strings.Replace(link, ":?", "://?", 1) 87 | } 88 | 89 | return link, nil 90 | } 91 | 92 | func (s *Session) GenerateDeeplink(connreq ConnectRequest, options ...linkOption) (string, error) { 93 | w := Wallet{UniversalURL: `tc://`} 94 | 95 | return s.GenerateUniversalLink(w, connreq, options...) 96 | } 97 | 98 | func WrapDeeplink(link string) string { 99 | link = url.QueryEscape(link) 100 | return fmt.Sprintf("%s?connect=%s", wrapURL, link) 101 | } 102 | 103 | func WithBackReturnStrategy() linkOption { 104 | return func(opts *linkOptions) { 105 | opts.ReturnStrategy = "back" 106 | } 107 | } 108 | 109 | func WithNoneReturnStrategy() linkOption { 110 | return func(opts *linkOptions) { 111 | opts.ReturnStrategy = "none" 112 | } 113 | } 114 | 115 | func WithURLReturnStrategy(url string) linkOption { 116 | return func(opts *linkOptions) { 117 | opts.ReturnStrategy = url 118 | } 119 | } 120 | 121 | func isTelegramURL(u *url.URL) bool { 122 | return u.Scheme == "tg" || u.Hostname() == "t.me" 123 | } 124 | 125 | func encodeTelegramURLParams(params string) string { 126 | params = strings.ReplaceAll(params, ".", "%2E") 127 | params = strings.ReplaceAll(params, "-", "%2D") 128 | params = strings.ReplaceAll(params, "_", "%5F") 129 | params = strings.ReplaceAll(params, "&", "-") 130 | params = strings.ReplaceAll(params, "=", "__") 131 | params = strings.ReplaceAll(params, "%", "--") 132 | 133 | return params 134 | } 135 | -------------------------------------------------------------------------------- /connect.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "golang.org/x/sync/errgroup" 10 | ) 11 | 12 | type connectResponse struct { 13 | Device deviceInfo `json:"device,omitempty"` 14 | Items []connectItemReply `json:"items,omitempty"` 15 | } 16 | 17 | type disconnectRequest struct { 18 | ID string `json:"id"` 19 | Method string `json:"method"` 20 | Params []any `json:"params"` 21 | } 22 | 23 | func (s *Session) Connect(ctx context.Context, wallets ...Wallet) (*connectResponse, error) { 24 | ctx, cancel := context.WithCancel(ctx) 25 | defer cancel() 26 | g, ctx := errgroup.WithContext(ctx) 27 | msgs := make(chan bridgeMessage) 28 | 29 | res := &connectResponse{} 30 | g.Go(func() error { 31 | for { 32 | select { 33 | case <-ctx.Done(): 34 | return ctx.Err() 35 | case msg := <-msgs: 36 | if msg.Message.Event == "connect" { 37 | cancel() 38 | 39 | msgID, err := msg.Message.ID.Int64() 40 | if err == nil { 41 | s.LastRequestID = uint64(msgID) 42 | } 43 | 44 | s.ClientID = msg.From 45 | s.BridgeURL = msg.BrdigeURL 46 | 47 | res.Items, err = getConnectItems(msg.Message.Payload.Items...) 48 | res.Device = msg.Message.Payload.Device 49 | return err 50 | } else if msg.Message.Event == "connect_error" { 51 | return getConnectError(msg.Message.Payload) 52 | } 53 | } 54 | } 55 | }) 56 | 57 | for _, u := range getBridgeURLs(wallets...) { 58 | u := u 59 | 60 | g.Go(func() error { 61 | return s.connectToBridge(ctx, u, msgs) 62 | }) 63 | } 64 | 65 | err := g.Wait() 66 | 67 | return res, err 68 | } 69 | 70 | func (s *Session) Disconnect(ctx context.Context, options ...bridgeMessageOption) error { 71 | ctx, cancel := context.WithCancel(ctx) 72 | defer cancel() 73 | g, ctx := errgroup.WithContext(ctx) 74 | msgs := make(chan bridgeMessage) 75 | 76 | id := s.LastRequestID + 1 77 | g.Go(func() error { 78 | req := disconnectRequest{ 79 | ID: strconv.FormatUint(id, 10), 80 | Method: "disconnect", 81 | Params: []any{}, 82 | } 83 | 84 | err := s.sendMessage(ctx, req, "", options...) 85 | if err == nil { 86 | s.LastRequestID = id 87 | } 88 | 89 | return err 90 | }) 91 | 92 | g.Go(func() error { 93 | for { 94 | select { 95 | case <-ctx.Done(): 96 | return ctx.Err() 97 | case msg := <-msgs: 98 | msgID, err := msg.Message.ID.Int64() 99 | if err == nil { 100 | s.LastRequestID = uint64(msgID) 101 | } 102 | 103 | if int64(id) == msgID { 104 | cancel() 105 | 106 | if msg.Message.Error != nil { 107 | if msg.Message.Error.Message != "" { 108 | return fmt.Errorf("tonconnect: %s", msg.Message.Error.Message) 109 | } 110 | 111 | switch msg.Message.Error.Code { 112 | case 1: 113 | return fmt.Errorf("tonconnect: bad request") 114 | case 100: 115 | return fmt.Errorf("tonconnect: unknown app") 116 | case 400: 117 | return fmt.Errorf("tonconnect: %q method is not supported", "sendTransaction") 118 | default: 119 | return fmt.Errorf("tonconnect: unknown disconnection error") 120 | } 121 | } 122 | } 123 | 124 | return nil 125 | } 126 | } 127 | }) 128 | 129 | g.Go(func() error { 130 | return s.connectToBridge(ctx, s.BridgeURL, msgs) 131 | }) 132 | 133 | err := g.Wait() 134 | 135 | return err 136 | } 137 | 138 | func getConnectError(payload payload) error { 139 | if payload.Message != "" { 140 | return fmt.Errorf("tonconnect: %s", payload.Message) 141 | } 142 | 143 | switch payload.Code { 144 | case 1: 145 | return fmt.Errorf("tonconnect: bad request") 146 | case 2: 147 | return fmt.Errorf("tonconnect: app manifest not found") 148 | case 3: 149 | return fmt.Errorf("tonconnect: app manifest content error") 150 | case 100: 151 | return fmt.Errorf("tonconnect: unknown app") 152 | case 300: 153 | return fmt.Errorf("tonconnect: user declined the connection") 154 | default: 155 | return fmt.Errorf("tonconnect: unknown connection error") 156 | } 157 | } 158 | 159 | func getConnectItems(items ...connectItemReply) ([]connectItemReply, error) { 160 | var errs []error 161 | var res []connectItemReply 162 | for _, item := range items { 163 | if item.Error != nil { 164 | if item.Error.Message != "" { 165 | errs = append(errs, fmt.Errorf("tonconnect: %s", item.Error.Message)) 166 | } else { 167 | switch item.Error.Code { 168 | case 400: 169 | errs = append(errs, fmt.Errorf("tonconnect: %q method is not supported", item.Name)) 170 | default: 171 | errs = append(errs, fmt.Errorf("tonconnect: %q method unknown error", item.Name)) 172 | } 173 | } 174 | } else { 175 | res = append(res, item) 176 | } 177 | } 178 | 179 | return res, errors.Join(errs...) 180 | } 181 | -------------------------------------------------------------------------------- /send.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "strconv" 9 | "time" 10 | 11 | "golang.org/x/sync/errgroup" 12 | ) 13 | 14 | type sendTransactionRequest struct { 15 | ID string `json:"id"` 16 | Method string `json:"method"` 17 | Params []string `json:"params"` 18 | } 19 | 20 | type Transaction struct { 21 | ValidUntil uint64 `json:"valid_until,omitempty"` 22 | Network string `json:"network,omitempty"` 23 | From string `json:"from,omitempty"` 24 | Messages []Message `json:"messages"` 25 | } 26 | 27 | type Message struct { 28 | Address string `json:"address"` 29 | Amount string `json:"amount"` 30 | Payload []byte `json:"payload,omitempty"` 31 | StateInit []byte `json:"stateInit,omitempty"` 32 | } 33 | 34 | type sendTransactionResponse struct { 35 | ID string `json:"id"` 36 | Result []byte `json:"result,omitempty"` 37 | Error *struct { 38 | Code uint64 `json:"code"` 39 | Message string `json:"message"` 40 | } `json:"error,omitempty"` 41 | } 42 | 43 | type txOpt = func(*Transaction) 44 | 45 | type msgOpt = func(*Message) 46 | 47 | func (s *Session) SendTransaction(ctx context.Context, tx Transaction, options ...bridgeMessageOption) ([]byte, error) { 48 | ctx, cancel := context.WithCancel(ctx) 49 | defer cancel() 50 | g, ctx := errgroup.WithContext(ctx) 51 | msgs := make(chan bridgeMessage) 52 | 53 | id := s.LastRequestID + 1 54 | g.Go(func() error { 55 | tr, err := json.Marshal(tx) 56 | if err != nil { 57 | return fmt.Errorf("tonconnect: failed to marshal transaction: %w", err) 58 | } 59 | 60 | req := sendTransactionRequest{ 61 | ID: strconv.FormatUint(id, 10), 62 | Method: "sendTransaction", 63 | Params: []string{string(tr)}, 64 | } 65 | 66 | err = s.sendMessage(ctx, req, "sendTransaction", options...) 67 | if err == nil { 68 | s.LastRequestID = id 69 | } 70 | 71 | return err 72 | }) 73 | 74 | var boc []byte 75 | g.Go(func() error { 76 | for { 77 | select { 78 | case <-ctx.Done(): 79 | return ctx.Err() 80 | case msg := <-msgs: 81 | msgID, err := msg.Message.ID.Int64() 82 | if err == nil { 83 | s.LastRequestID = uint64(msgID) 84 | } 85 | 86 | if int64(id) == msgID { 87 | if msg.Message.Error != nil { 88 | if msg.Message.Error.Message != "" { 89 | return fmt.Errorf("tonconnect: %s", msg.Message.Error.Message) 90 | } 91 | 92 | switch msg.Message.Error.Code { 93 | case 1: 94 | return fmt.Errorf("tonconnect: bad request") 95 | case 100: 96 | return fmt.Errorf("tonconnect: unknown app") 97 | case 300: 98 | return fmt.Errorf("tonconnect: user declined the transaction") 99 | case 400: 100 | return fmt.Errorf("tonconnect: %q method is not supported", "sendTransaction") 101 | default: 102 | return fmt.Errorf("tonconnect: unknown transaction send error") 103 | } 104 | } 105 | 106 | cancel() 107 | 108 | res, ok := msg.Message.Result.(string) 109 | if !ok { 110 | return fmt.Errorf("tonconnect: transaction result expected to be of type %q", "string") 111 | } 112 | 113 | boc, err = base64.StdEncoding.DecodeString(res) 114 | if err != nil { 115 | return fmt.Errorf("tonconnect: failed to decode transaction result bag of cells") 116 | } 117 | 118 | return nil 119 | } 120 | } 121 | } 122 | }) 123 | 124 | g.Go(func() error { 125 | return s.connectToBridge(ctx, s.BridgeURL, msgs) 126 | }) 127 | 128 | err := g.Wait() 129 | 130 | return boc, err 131 | } 132 | 133 | func NewTransaction(options ...txOpt) (*Transaction, error) { 134 | tx := &Transaction{} 135 | for _, opt := range options { 136 | opt(tx) 137 | } 138 | 139 | return tx, nil 140 | } 141 | 142 | func NewMessage(address string, amount string, options ...msgOpt) (*Message, error) { 143 | msg := &Message{Address: address, Amount: amount} 144 | for _, opt := range options { 145 | opt(msg) 146 | } 147 | 148 | return msg, nil 149 | } 150 | 151 | func WithTimeout(timeout time.Duration) txOpt { 152 | return func(tx *Transaction) { 153 | tx.ValidUntil = uint64(time.Now().Add(timeout).Unix()) 154 | } 155 | } 156 | 157 | func WithMainnet() txOpt { 158 | return func(tx *Transaction) { 159 | tx.Network = "-239" 160 | } 161 | } 162 | 163 | func WithTestnet() txOpt { 164 | return func(tx *Transaction) { 165 | tx.Network = "-3" 166 | } 167 | } 168 | 169 | func WithFrom(from string) txOpt { 170 | return func(tx *Transaction) { 171 | tx.From = from 172 | } 173 | } 174 | 175 | func WithMessage(msg Message) txOpt { 176 | return func(tx *Transaction) { 177 | tx.Messages = append(tx.Messages, msg) 178 | } 179 | } 180 | 181 | func WithPayload(payload []byte) msgOpt { 182 | return func(msg *Message) { 183 | msg.Payload = payload 184 | } 185 | } 186 | 187 | func WithStateInit(stateInit []byte) msgOpt { 188 | return func(msg *Message) { 189 | msg.StateInit = stateInit 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package tonconnect 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "encoding/hex" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "net/http" 13 | "net/url" 14 | "strconv" 15 | 16 | "github.com/kevinburke/nacl" 17 | "github.com/kevinburke/nacl/box" 18 | "github.com/tmaxmax/go-sse" 19 | ) 20 | 21 | type Session struct { 22 | ID nacl.Key `json:"id"` 23 | PrivateKey nacl.Key `json:"private_key"` 24 | ClientID nacl.Key `json:"client_id,omitempty"` 25 | BridgeURL string `json:"brdige_url,omitempty"` 26 | LastEventID uint64 `json:"last_event_id,string,omitempty"` 27 | LastRequestID uint64 `json:"last_request_id,string,omitempty"` 28 | } 29 | 30 | type bridgeMessageOptions struct { 31 | TTL string 32 | Topic string 33 | } 34 | 35 | type bridgeMessageOption = func(*bridgeMessageOptions) 36 | 37 | func (s *Session) MarshalJSON() ([]byte, error) { 38 | type Alias Session 39 | return json.Marshal(&struct { 40 | ID string `json:"id"` 41 | PrivateKey string `json:"private_key"` 42 | ClientID string `json:"client_id,omitempty"` 43 | *Alias 44 | }{ 45 | ID: keyToBase64(s.ID), 46 | PrivateKey: keyToBase64(s.PrivateKey), 47 | ClientID: keyToBase64(s.ClientID), 48 | Alias: (*Alias)(s), 49 | }) 50 | } 51 | 52 | func (s *Session) UnmarshalJSON(data []byte) error { 53 | type Alias Session 54 | aux := &struct { 55 | ID string `json:"id"` 56 | PrivateKey string `json:"private_key"` 57 | ClientID string `json:"client_id,omitempty"` 58 | *Alias 59 | }{ 60 | Alias: (*Alias)(s), 61 | } 62 | if err := json.Unmarshal(data, aux); err != nil { 63 | return err 64 | } 65 | var err error 66 | s.ID, err = base64ToKey(aux.ID) 67 | if err != nil { 68 | return err 69 | } 70 | s.PrivateKey, err = base64ToKey(aux.PrivateKey) 71 | if err != nil { 72 | return err 73 | } 74 | s.ClientID, err = base64ToKey(aux.ClientID) 75 | if err != nil { 76 | return err 77 | } 78 | return nil 79 | } 80 | 81 | func NewSession() (*Session, error) { 82 | id, pk, err := box.GenerateKey(rand.Reader) 83 | if err != nil { 84 | return nil, fmt.Errorf("tonconnect: failed to generate key pair: %w", err) 85 | } 86 | 87 | s := &Session{ID: id, PrivateKey: pk, LastRequestID: 1} 88 | 89 | return s, nil 90 | } 91 | 92 | func (s *Session) connectToBridge(ctx context.Context, bridgeURL string, msgs chan<- bridgeMessage) error { 93 | if s.ID == nil || s.PrivateKey == nil { 94 | return fmt.Errorf("tonconnect: session key pair is empty") 95 | } 96 | 97 | u, err := url.Parse(bridgeURL) 98 | if err != nil { 99 | return fmt.Errorf("tonconnect: failed to parse bridge URL: %w", err) 100 | } 101 | 102 | u = u.JoinPath("/events") 103 | q := u.Query() 104 | q.Set("client_id", hex.EncodeToString(s.ID[:])) 105 | if s.LastEventID > 0 { 106 | q.Set("last_event_id", strconv.FormatUint(uint64(s.LastEventID), 10)) 107 | } 108 | u.RawQuery = q.Encode() 109 | 110 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) 111 | if err != nil { 112 | return fmt.Errorf("tonconnect: failed to initialize HTTP request: %w", err) 113 | } 114 | 115 | conn := sse.NewConnection(req) 116 | unsub := conn.SubscribeEvent("message", func(e sse.Event) { 117 | var bmsg struct { 118 | From string `json:"from"` 119 | Message []byte `json:"message"` 120 | } 121 | if err := json.Unmarshal([]byte(e.Data), &bmsg); err == nil { 122 | var msg walletMessage 123 | if clientID, err := s.decrypt(bmsg.From, bmsg.Message, &msg); err == nil { 124 | msgs <- bridgeMessage{BrdigeURL: bridgeURL, From: clientID, Message: msg} 125 | id, err := strconv.ParseUint(e.LastEventID, 10, 64) 126 | if err == nil { 127 | s.LastEventID = id 128 | } 129 | } 130 | } 131 | }) 132 | defer unsub() 133 | 134 | if err := conn.Connect(); !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { 135 | return fmt.Errorf("tonconnect: failed to connect to bridge: %w", err) 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func (s *Session) sendMessage(ctx context.Context, msg any, topic string, options ...bridgeMessageOption) error { 142 | if s.ID == nil || s.PrivateKey == nil || s.ClientID == nil || s.BridgeURL == "" { 143 | return fmt.Errorf("tonconnect: session not established") 144 | } 145 | 146 | opts := &bridgeMessageOptions{TTL: "300"} 147 | for _, opt := range options { 148 | opt(opts) 149 | } 150 | 151 | u, err := url.Parse(s.BridgeURL) 152 | if err != nil { 153 | return fmt.Errorf("tonconnect: failed to parse bridge URL: %w", err) 154 | } 155 | 156 | u = u.JoinPath("/message") 157 | q := u.Query() 158 | q.Set("client_id", hex.EncodeToString(s.ID[:])) 159 | q.Set("to", hex.EncodeToString(s.ClientID[:])) 160 | if opts.TTL != "" { 161 | q.Set("ttl", opts.TTL) 162 | } 163 | if topic != "" { 164 | q.Set("topic", topic) 165 | } 166 | u.RawQuery = q.Encode() 167 | 168 | data, err := s.encrypt(msg) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | body := bytes.NewBuffer([]byte(base64.StdEncoding.EncodeToString(data))) 174 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), body) 175 | req.Header.Set("Content-Type", "text/plain") 176 | if err != nil { 177 | return fmt.Errorf("tonconnect: failed to initialize HTTP request: %w", err) 178 | } 179 | res, err := http.DefaultClient.Do(req) 180 | if err != nil { 181 | return fmt.Errorf("tonconnect: failed to send message: %w", err) 182 | } 183 | defer res.Body.Close() 184 | if res.StatusCode != http.StatusOK { 185 | // TODO: parse response body according to https://github.com/ton-connect/bridge implementation 186 | return fmt.Errorf("tonconnect: failed to send message") 187 | } 188 | 189 | return nil 190 | } 191 | 192 | func (s *Session) encrypt(msg any) ([]byte, error) { 193 | data, err := json.Marshal(msg) 194 | if err != nil { 195 | return nil, fmt.Errorf("tonconnect: failed to marshal message to encrypt: %w", err) 196 | } 197 | 198 | return box.EasySeal(data, s.ClientID, s.PrivateKey), nil 199 | } 200 | 201 | func (s *Session) decrypt(from string, msg []byte, v any) (nacl.Key, error) { 202 | clientID, err := nacl.Load(from) 203 | if err != nil { 204 | return clientID, fmt.Errorf("tonconnect: failed to load client ID: %w", err) 205 | } 206 | 207 | if s.ClientID != nil && !bytes.Equal(s.ClientID[:], clientID[:]) { 208 | return clientID, fmt.Errorf("tonconnect: session and bridge message client IDs don't match") 209 | } 210 | 211 | data, err := box.EasyOpen(msg, clientID, s.PrivateKey) 212 | if err != nil { 213 | return clientID, fmt.Errorf("tonconnect: failed to decrypt bridge message: %w", err) 214 | } 215 | 216 | err = json.Unmarshal(data, v) 217 | if err != nil { 218 | return clientID, fmt.Errorf("tonconnect: failed to unmarshal decrypted data: %w", err) 219 | } 220 | 221 | return clientID, nil 222 | } 223 | 224 | func keyToBase64(key nacl.Key) string { 225 | if key == nil { 226 | return "" 227 | } 228 | return base64.StdEncoding.EncodeToString(key[:]) 229 | } 230 | 231 | func base64ToKey(b64key string) (nacl.Key, error) { 232 | if len(b64key) != 44 { 233 | return nil, fmt.Errorf("incorrect base64 key length: %d, should be 44", len(b64key)) 234 | } 235 | keyBytes, err := base64.StdEncoding.DecodeString(b64key) 236 | if err != nil { 237 | return nil, err 238 | } 239 | if len(keyBytes) != nacl.KeySize { 240 | return nil, fmt.Errorf("incorrect key length: %d", len(keyBytes)) 241 | } 242 | key := new([nacl.KeySize]byte) 243 | copy(key[:], keyBytes) 244 | return key, nil 245 | } 246 | 247 | func WithTTL(ttl uint64) bridgeMessageOption { 248 | return func(opts *bridgeMessageOptions) { 249 | opts.TTL = strconv.FormatUint(ttl, 10) 250 | } 251 | } 252 | --------------------------------------------------------------------------------