├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── _examples ├── authorization │ └── main.go ├── blaze │ ├── README.md │ └── main.go ├── collectibles │ └── main.go ├── echo_api │ ├── README.md │ └── main.go ├── echo_proxy │ ├── README.md │ └── main.go ├── encryptmessage │ └── main.go ├── go.mod ├── go.sum ├── mixinnet │ └── main.go ├── multisig │ └── main.go ├── nodemonitor │ ├── main.go │ └── monitor.go ├── oauth │ └── main.go ├── oauth_ed25519 │ └── main.go └── wallet │ └── main.go ├── ack.go ├── address_mix.go ├── app.go ├── app_test.go ├── assets_test.go ├── attachment.go ├── attachment_test.go ├── auth.go ├── authorization.go ├── blaze.go ├── blaze_ack.go ├── blaze_test.go ├── circle.go ├── circle_test.go ├── client.go ├── client_test.go ├── code.go ├── code_test.go ├── context.go ├── conversation.go ├── conversation_test.go ├── curve.go ├── ed25519.go ├── endpoint.go ├── error.go ├── error_test.go ├── fiats.go ├── go.mod ├── go.sum ├── keystore.go ├── keystore_test.go ├── legacy_address.go ├── legacy_address_test.go ├── legacy_assets.go ├── legacy_collectible.go ├── legacy_collectible_collection.go ├── legacy_collectible_mint.go ├── legacy_collectible_mint_test.go ├── legacy_collectible_token.go ├── legacy_ghost_key.go ├── legacy_multisig_assets.go ├── legacy_multisigs.go ├── legacy_payment.go ├── legacy_payment_test.go ├── legacy_snapshot.go ├── legacy_transaction_external.go ├── legacy_transaction_external_test.go ├── legacy_transaction_raw.go ├── legacy_transfer.go ├── legacy_withdraw.go ├── logo └── logo.png ├── messages.go ├── messages_encrypt.go ├── mixinnet ├── address.go ├── address_test.go ├── client.go ├── context.go ├── decoding.go ├── encoding.go ├── error.go ├── hash.go ├── key.go ├── key_test.go ├── number.go ├── rpc.go ├── rpc_hosts.go ├── rpc_info.go ├── rpc_test.go ├── rpc_utxo.go ├── signature.go ├── transaction.go ├── transaction_extra.go ├── transaction_input.go ├── transaction_script.go ├── transaction_test.go ├── transaction_v1.go ├── util.go └── validation.go ├── network.go ├── nft └── mint.go ├── oauth.go ├── oauth_ed25519.go ├── ownership.go ├── pin.go ├── pin_test.go ├── relationships.go ├── request.go ├── request_test.go ├── safe_asset.go ├── safe_asset_test.go ├── safe_deposit.go ├── safe_deposit_test.go ├── safe_ghost_keys.go ├── safe_ghost_keys_test.go ├── safe_inscription.go ├── safe_inscription_test.go ├── safe_migrate.go ├── safe_migrate_test.go ├── safe_multisigs.go ├── safe_multisigs_test.go ├── safe_snapshot.go ├── safe_snapshot_test.go ├── safe_transaction_request.go ├── safe_utxo.go ├── safe_utxo_test.go ├── session.go ├── sign.go ├── sign_test.go ├── tip.go ├── transaction_input.go ├── transaction_input_test.go ├── transaction_signer.go ├── turn.go ├── url_scheme.go ├── url_scheme_test.go ├── user.go ├── user_test.go └── utils.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for Go 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | config.*.go 15 | demo/demo* 16 | 17 | .idea 18 | .vscode 19 | /testdata/keystore*.json 20 | vendor 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fox.ONE 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # mixin-sdk-go 4 | Golang sdk for Mixin Network & Mixin Messenger 5 | 6 | ## Install 7 | 8 | `go get -u github.com/fox-one/mixin-sdk-go/v2` 9 | 10 | ## Features 11 | 12 | * **Comprehensive** most of the Mixin Network & Mixin Messenger api supported 13 | * **Security** verify Response `X-Request-ID` & signature automatically 14 | * **Flexible** initialize [Client](https://github.com/fox-one/mixin-sdk-go/blob/master/client.go) from `keystore`, `ed25519_oauth_token` or `access_token` 15 | 16 | ## Examples 17 | 18 | See [_examples/](https://github.com/fox-one/mixin-sdk-go/blob/master/_examples/) for a variety of examples. 19 | 20 | **Quick Start** 21 | 22 | ```go 23 | package main 24 | 25 | import ( 26 | "context" 27 | "log" 28 | 29 | "github.com/fox-one/mixin-sdk-go/v2" 30 | ) 31 | 32 | func main() { 33 | ctx := context.Background() 34 | s := &mixin.Keystore{ 35 | ClientID: "", 36 | SessionID: "", 37 | PrivateKey: "", 38 | PinToken: "", 39 | } 40 | 41 | client, err := mixin.NewFromKeystore(s) 42 | if err != nil { 43 | log.Panicln(err) 44 | } 45 | 46 | user, err := client.UserMe(ctx) 47 | if err != nil { 48 | log.Printf("UserMe: %v", err) 49 | return 50 | } 51 | 52 | log.Println("user id", user.UserID) 53 | } 54 | ``` 55 | 56 | ## Error handling? 57 | 58 | check error code by `mixin.IsErrorCodes` 59 | 60 | ```go 61 | if _, err := client.UserMe(ctx); err != nil { 62 | switch { 63 | case mixin.IsErrorCodes(err,mixin.Unauthorized,mixin.EndpointNotFound): 64 | // handle unauthorized error 65 | case mixin.IsErrorCodes(err,mixin.InsufficientBalance): 66 | // handle insufficient balance error 67 | default: 68 | } 69 | } 70 | ``` 71 | 72 | -------------------------------------------------------------------------------- /_examples/authorization/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "flag" 7 | "log" 8 | "net/url" 9 | "os" 10 | "strings" 11 | 12 | "github.com/fox-one/mixin-sdk-go/v2" 13 | ) 14 | 15 | var ( 16 | clientID = flag.String("client_id", "", "client id") 17 | scope = flag.String("scope", "PROFILE:READ", "oauth scope") 18 | config = flag.String("config", "", "keystore file path") 19 | callback = flag.Bool("callback", false, "callback") 20 | ) 21 | 22 | func main() { 23 | flag.Parse() 24 | ctx := context.Background() 25 | 26 | var cfg struct { 27 | mixin.Keystore 28 | Pin string `json:"pin"` 29 | } 30 | 31 | // Open the keystore file 32 | f, err := os.Open(*config) 33 | if err != nil { 34 | log.Panicln(err) 35 | } 36 | 37 | if err := json.NewDecoder(f).Decode(&cfg); err != nil { 38 | log.Panicln(err) 39 | } 40 | 41 | client, err := mixin.NewFromKeystore(&cfg.Keystore) 42 | if err != nil { 43 | log.Panicln(err) 44 | } 45 | 46 | scopes := strings.Fields(*scope) 47 | 48 | var verifier, challenge string 49 | 50 | if !*callback { 51 | verifier, challenge = mixin.RandomCodeChallenge() 52 | } 53 | 54 | auth, err := mixin.RequestAuthorization(ctx, *clientID, scopes, challenge) 55 | if err != nil { 56 | log.Panicln("request authorization failed", err) 57 | } 58 | 59 | log.Println("auth id is", auth.AuthorizationID) 60 | auth, err = client.Authorize(ctx, auth.AuthorizationID, scopes, cfg.Pin) 61 | if err != nil { 62 | log.Panicln("authorize failed", err) 63 | } 64 | 65 | if auth.AuthorizationCode == "" { 66 | log.Println("access denied") 67 | return 68 | } 69 | 70 | log.Println("auth code is", auth.AuthorizationCode) 71 | 72 | if showCallback := *callback; showCallback { 73 | if callbackURL, err := url.Parse(auth.App.RedirectURI); err == nil { 74 | q := url.Values{} 75 | q.Set("code", auth.AuthorizationCode) 76 | callbackURL.RawQuery = q.Encode() 77 | log.Println("callback url is", callbackURL.String()) 78 | } 79 | } else { 80 | token, _, err := mixin.AuthorizeToken(ctx, *clientID, "", auth.AuthorizationCode, verifier) 81 | if err != nil { 82 | log.Panicln("authorize token failed", err) 83 | } 84 | 85 | log.Println("token is", token) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /_examples/blaze/README.md: -------------------------------------------------------------------------------- 1 | # Blaze Example 2 | 3 | > Loop Blaze messages and reply 4 | 5 | ## Usage 6 | 7 | 1. Run ```go run main.go --config your_keystore_path.json``` 8 | 2. Send any message to this bot 9 | 10 | -------------------------------------------------------------------------------- /_examples/blaze/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "flag" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/fox-one/mixin-sdk-go/v2" 14 | "github.com/gofrs/uuid" 15 | ) 16 | 17 | var ( 18 | // Specify the keystore file in the -config parameter 19 | config = flag.String("config", "", "keystore file path") 20 | ) 21 | 22 | func main() { 23 | // Use flag package to parse the parameters 24 | flag.Parse() 25 | 26 | // Open the keystore file 27 | f, err := os.Open(*config) 28 | if err != nil { 29 | log.Panicln(err) 30 | } 31 | 32 | // Read the keystore file as json into mixin.Keystore, which is a go struct 33 | var store mixin.Keystore 34 | if err := json.NewDecoder(f).Decode(&store); err != nil { 35 | log.Panicln(err) 36 | } 37 | 38 | // Create a Mixin Client from the keystore, which is the instance to invoke Mixin APIs 39 | client, err := mixin.NewFromKeystore(&store) 40 | if err != nil { 41 | log.Panicln(err) 42 | } 43 | 44 | // Prepare the message loop that handle every incoming messages, 45 | // and reply it with the same content. 46 | // We use a callback function to handle them. 47 | h := func(ctx context.Context, msg *mixin.MessageView, userID string) error { 48 | // if there is no valid user id in the message, drop it 49 | if userID, _ := uuid.FromString(msg.UserID); userID == uuid.Nil { 50 | return nil 51 | } 52 | 53 | // The incoming message's message ID, which is an UUID. 54 | id, _ := uuid.FromString(msg.MessageID) 55 | 56 | // Create a request 57 | reply := &mixin.MessageRequest{ 58 | // Reuse the conversation between the sender and the bot. 59 | // There is an unique UUID for each conversation. 60 | ConversationID: msg.ConversationID, 61 | // The user ID of the recipient. 62 | // The bot will reply messages, so here is the sender's ID of each incoming message. 63 | RecipientID: msg.UserID, 64 | // Create a new message id to reply, it should be an UUID never used by any other message. 65 | // Create it with a "reply" and the incoming message ID. 66 | MessageID: uuid.NewV5(id, "reply").String(), 67 | // The bot just reply the same category and the same content of the incoming message 68 | // So, we copy the category and data 69 | Category: msg.Category, 70 | Data: msg.Data, 71 | } 72 | // Send the response 73 | return client.SendMessage(ctx, reply) 74 | } 75 | 76 | ctx, stop := signal.NotifyContext( 77 | context.Background(), 78 | syscall.SIGINT, 79 | syscall.SIGTERM, 80 | ) 81 | defer stop() 82 | 83 | // Start the message loop. 84 | for { 85 | select { 86 | case <-ctx.Done(): 87 | return 88 | case <-time.After(time.Second): 89 | // Pass the callback function into the `BlazeListenFunc` 90 | if err := client.LoopBlaze(ctx, mixin.BlazeListenFunc(h)); err != nil { 91 | log.Printf("LoopBlaze: %v", err) 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /_examples/collectibles/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "math/rand" 10 | "os" 11 | "time" 12 | 13 | "github.com/fox-one/mixin-sdk-go/v2" 14 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 15 | "github.com/gofrs/uuid" 16 | ) 17 | 18 | var ( 19 | // Specify the keystore file in the -config parameter 20 | config = flag.String("config", "", "keystore file path") 21 | pin = flag.String("pin", "", "pin") 22 | mint = flag.Bool("mint", false, "mint new collectibles") 23 | ) 24 | 25 | func main() { 26 | // Use flag package to parse the parameters 27 | flag.Parse() 28 | 29 | // Open the keystore file 30 | f, err := os.Open(*config) 31 | if err != nil { 32 | log.Panicln(err) 33 | } 34 | 35 | // Read the keystore file as json into mixin.Keystore, which is a go struct 36 | var store mixin.Keystore 37 | if err := json.NewDecoder(f).Decode(&store); err != nil { 38 | log.Panicln(err) 39 | } 40 | 41 | // Create a Mixin Client from the keystore, which is the instance to invoke Mixin APIs 42 | client, err := mixin.NewFromKeystore(&store) 43 | if err != nil { 44 | log.Panicln(err) 45 | } 46 | 47 | ctx := context.Background() 48 | 49 | if *mint { 50 | id, _ := uuid.NewV4() 51 | token := rand.Int63() 52 | tr := mixin.NewMintCollectibleTransferInput(id.String(), id.String(), token, mixin.MetaHash(id.Bytes())) 53 | payment, err := client.VerifyPayment(ctx, tr) 54 | if err != nil { 55 | log.Panicln(err) 56 | } 57 | 58 | fmt.Println("mint collectibles", id.String(), mixin.URL.Codes(payment.CodeID)) 59 | return 60 | } 61 | 62 | mixin.GetRestyClient().Debug = true 63 | 64 | outputs, err := client.ReadCollectibleOutputs(ctx, []string{client.ClientID}, 1, "", time.Unix(0, 0), 100) 65 | if err != nil { 66 | log.Panicln(err) 67 | } 68 | 69 | for _, output := range outputs { 70 | switch output.State { 71 | case mixin.CollectibleOutputStateUnspent: 72 | token, err := client.ReadCollectiblesToken(ctx, output.TokenID) 73 | if err != nil { 74 | log.Panicln(err) 75 | } 76 | 77 | handleUnspentOutput(ctx, client, output, token) 78 | case mixin.CollectibleOutputStateSigned: 79 | handleSignedOutput(ctx, output) 80 | } 81 | } 82 | } 83 | 84 | func handleUnspentOutput(ctx context.Context, client *mixin.Client, output *mixin.CollectibleOutput, token *mixin.CollectibleToken) { 85 | log.Println("handle unspent output", output.OutputID, token.TokenID) 86 | 87 | receivers := []string{"8017d200-7870-4b82-b53f-74bae1d2dad7"} 88 | tx, err := client.MakeCollectibleTransaction(ctx, mixinnet.TxVersionReferences, output, token, receivers, 1) 89 | if err != nil { 90 | log.Panicln(err) 91 | } 92 | 93 | signedTx, err := tx.Dump() 94 | if err != nil { 95 | log.Panicln(err) 96 | } 97 | 98 | // create sign request 99 | req, err := client.CreateCollectibleRequest(ctx, mixin.CollectibleRequestActionSign, signedTx) 100 | if err != nil { 101 | log.Panicln(err) 102 | } 103 | 104 | // sign 105 | _, err = client.SignCollectibleRequest(ctx, req.RequestID, *pin) 106 | if err != nil { 107 | log.Panicln(err) 108 | } 109 | } 110 | 111 | func handleSignedOutput(ctx context.Context, output *mixin.CollectibleOutput) { 112 | log.Println("handle signed output", output.OutputID) 113 | 114 | tx, err := mixinnet.TransactionFromRaw(output.SignedTx) 115 | if err != nil { 116 | log.Panicln(err) 117 | } 118 | 119 | if tx.AggregatedSignature == nil { 120 | return 121 | } 122 | 123 | client := mixinnet.NewClient(mixinnet.DefaultLegacyConfig) 124 | if _, err := client.SendRawTransaction(ctx, output.SignedTx); err != nil { 125 | log.Panicln(err) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /_examples/echo_api/README.md: -------------------------------------------------------------------------------- 1 | # Echo Api Example 2 | 3 | [Echo Api](https://github.com/fox-one/echo/blob/master/cmd/echos/main.go) 是 mixin api 的一个代理, 4 | 可以在没有 Auth Token 的情况下访问 ```api.mixin.one``` 的所有 GET 请求。适用于在没有 ```keystore``` 的情况下访问 5 | 用户详情,汇率等接口。 6 | 7 | ## Usage 8 | 9 | 1. Run ```go run main.go``` 10 | 11 | -------------------------------------------------------------------------------- /_examples/echo_api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2" 9 | ) 10 | 11 | func main() { 12 | ctx := context.Background() 13 | 14 | mixin.UseApiHost(mixin.EchoApiHost) 15 | client := &mixin.Client{} 16 | 17 | fiats, err := client.ReadExchangeRates(ctx) 18 | if err != nil { 19 | log.Printf("ReadExchangeRates: %v", err) 20 | return 21 | } 22 | 23 | for _, rate := range fiats { 24 | fmt.Println(rate.Code, rate.Rate) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /_examples/echo_proxy/README.md: -------------------------------------------------------------------------------- 1 | # Echo Proxy 2 | 3 | Echo Proxy 是 mixin api 的一个代理, 4 | 可以在没有 Auth Token 的情况下访问 ```api.mixin.one``` 的所有 GET 请求。适用于在没有 ```keystore``` 的情况下访问 5 | 用户详情,汇率等接口。 6 | 7 | ## Requirement 8 | 9 | Go v1.15 10 | 11 | ## Usage 12 | 13 | * Run `go run main.go --key keystore_path.json --port 8888` 14 | 15 | See [main.go](main.go) for details 16 | 17 | -------------------------------------------------------------------------------- /_examples/echo_proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/http/httputil" 12 | "net/url" 13 | "os" 14 | "time" 15 | 16 | "github.com/fox-one/mixin-sdk-go/v2" 17 | "github.com/oxtoacart/bpool" 18 | "github.com/rs/cors" 19 | ) 20 | 21 | var cfg struct { 22 | keystore string 23 | port int 24 | getOnly bool 25 | endpoint string 26 | } 27 | 28 | var ( 29 | xAuthorization = http.CanonicalHeaderKey("Authorization") 30 | ) 31 | 32 | func main() { 33 | flag.StringVar(&cfg.keystore, "key", "", "keystore file path") 34 | flag.IntVar(&cfg.port, "port", 9999, "server port") 35 | flag.BoolVar(&cfg.getOnly, "get", false, "only allow GET method") 36 | flag.StringVar(&cfg.endpoint, "endpoint", mixin.DefaultApiHost, "mixin api host") 37 | flag.Parse() 38 | 39 | f, err := os.Open(cfg.keystore) 40 | if err != nil { 41 | log.Panicln(err) 42 | } 43 | 44 | var keystore mixin.Keystore 45 | if err := json.NewDecoder(f).Decode(&keystore); err != nil { 46 | log.Panicln(err) 47 | } 48 | 49 | _ = f.Close() 50 | 51 | auth, err := mixin.AuthFromKeystore(&keystore) 52 | if err != nil { 53 | log.Panicln(err) 54 | } 55 | 56 | endpoint, _ := url.Parse(cfg.endpoint) 57 | 58 | proxy := &httputil.ReverseProxy{ 59 | BufferPool: bpool.NewBytePool(16, 1024*8), 60 | Director: func(req *http.Request) { 61 | if token := req.Header.Get(xAuthorization); token == "" { 62 | var body []byte 63 | if req.Body != nil { 64 | body, _ = ioutil.ReadAll(req.Body) 65 | _ = req.Body.Close() 66 | req.Body = ioutil.NopCloser(bytes.NewReader(body)) 67 | } 68 | 69 | sig := mixin.SignRaw(req.Method, req.URL.String(), body) 70 | token := auth.SignToken(sig, mixin.RandomTraceID(), time.Minute) 71 | req.Header.Set(xAuthorization, "Bearer "+token) 72 | } 73 | 74 | // mixin api server 屏蔽来自 proxy 的请求 75 | // 这里在转发请求的时候不带上 X-Forwarded-For 76 | // https://github.com/golang/go/issues/38079 go 1.15 上线 77 | req.Header["X-Forwarded-For"] = nil 78 | 79 | req.Host = endpoint.Host 80 | req.URL.Host = endpoint.Host 81 | req.URL.Scheme = endpoint.Scheme 82 | }, 83 | } 84 | 85 | var handler http.Handler = proxy 86 | 87 | if cfg.getOnly { 88 | handler = allowMethod(http.MethodGet)(proxy) 89 | } 90 | 91 | // cors 92 | handler = cors.AllowAll().Handler(handler) 93 | 94 | svr := &http.Server{ 95 | Addr: fmt.Sprintf(":%d", cfg.port), 96 | Handler: handler, 97 | } 98 | 99 | if err := svr.ListenAndServe(); err != nil { 100 | log.Fatal(err) 101 | } 102 | } 103 | 104 | func allowMethod(methods ...string) func(http.Handler) http.Handler { 105 | return func(next http.Handler) http.Handler { 106 | fn := func(w http.ResponseWriter, r *http.Request) { 107 | for _, method := range methods { 108 | if r.Method == method { 109 | next.ServeHTTP(w, r) 110 | return 111 | } 112 | } 113 | 114 | w.WriteHeader(http.StatusMethodNotAllowed) 115 | } 116 | 117 | return http.HandlerFunc(fn) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /_examples/encryptmessage/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "flag" 8 | "log" 9 | "os" 10 | 11 | "github.com/fox-one/mixin-sdk-go/v2" 12 | ) 13 | 14 | var ( 15 | // Specify the keystore file in the -config parameter 16 | config = flag.String("config", "", "keystore file path") 17 | text = flag.String("text", "hello world", "text message") 18 | ) 19 | 20 | func main() { 21 | // Use flag package to parse the parameters 22 | flag.Parse() 23 | 24 | // Open the keystore file 25 | f, err := os.Open(*config) 26 | if err != nil { 27 | log.Panicln(err) 28 | } 29 | 30 | // Read the keystore file as json into mixin.Keystore, which is a go struct 31 | var store mixin.Keystore 32 | if err := json.NewDecoder(f).Decode(&store); err != nil { 33 | log.Panicln(err) 34 | } 35 | 36 | // Create a Mixin Client from the keystore, which is the instance to invoke Mixin APIs 37 | client, err := mixin.NewFromKeystore(&store) 38 | if err != nil { 39 | log.Panicln(err) 40 | } 41 | 42 | ctx := context.Background() 43 | 44 | me, err := client.UserMe(ctx) 45 | if err != nil { 46 | log.Fatalln(err) 47 | } 48 | 49 | if me.App == nil { 50 | log.Fatalln("use a bot keystore instead") 51 | } 52 | 53 | receiptID := me.App.CreatorID 54 | 55 | sessions, err := client.FetchSessions(ctx, []string{receiptID}) 56 | if err != nil { 57 | log.Fatalln(err) 58 | } 59 | 60 | _ = sessions 61 | 62 | req := &mixin.MessageRequest{ 63 | ConversationID: mixin.UniqueConversationID(client.ClientID, receiptID), 64 | RecipientID: receiptID, 65 | MessageID: mixin.RandomTraceID(), 66 | Category: mixin.MessageCategoryPlainText, 67 | Data: base64.StdEncoding.EncodeToString([]byte(*text)), 68 | } 69 | 70 | if err := client.EncryptMessageRequest(req, sessions); err != nil { 71 | log.Fatalln(err) 72 | } 73 | 74 | receipts, err := client.SendEncryptedMessages(ctx, []*mixin.MessageRequest{req}) 75 | if err != nil { 76 | log.Fatalln(err) 77 | } 78 | 79 | b, _ := json.Marshal(receipts) 80 | log.Println(string(b)) 81 | } 82 | -------------------------------------------------------------------------------- /_examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fox-one/mixin-sdk-go/v2/example 2 | 3 | go 1.21.0 4 | 5 | replace github.com/fox-one/mixin-sdk-go/v2 => ../ 6 | 7 | require ( 8 | github.com/fox-one/mixin-sdk-go/v2 v2.0.0-00010101000000-000000000000 9 | github.com/gofrs/uuid v4.4.0+incompatible 10 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c 11 | github.com/rs/cors v1.10.1 12 | github.com/shopspring/decimal v1.3.1 13 | github.com/sirupsen/logrus v1.9.3 14 | golang.org/x/sync v0.6.0 15 | ) 16 | 17 | require ( 18 | filippo.io/edwards25519 v1.1.0 // indirect 19 | github.com/btcsuite/btcutil v1.0.2 // indirect 20 | github.com/fox-one/msgpack v1.0.0 // indirect 21 | github.com/go-resty/resty/v2 v2.12.0 // indirect 22 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 23 | github.com/golang/protobuf v1.5.4 // indirect 24 | github.com/gorilla/websocket v1.5.1 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 26 | github.com/vmihailenco/tagparser v0.1.2 // indirect 27 | github.com/zeebo/blake3 v0.2.3 // indirect 28 | golang.org/x/crypto v0.21.0 // indirect 29 | golang.org/x/net v0.22.0 // indirect 30 | golang.org/x/sys v0.18.0 // indirect 31 | google.golang.org/appengine v1.6.8 // indirect 32 | google.golang.org/protobuf v1.33.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /_examples/nodemonitor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "golang.org/x/sync/errgroup" 11 | ) 12 | 13 | var ( 14 | hosts = flag.String("hosts", "", "mixin node rpc hosts, join with ';'") 15 | ) 16 | 17 | func main() { 18 | flag.Parse() 19 | 20 | if *hosts == "" { 21 | log.Println("./nodemonitor -hosts a;b;c") 22 | return 23 | } 24 | 25 | g, ctx := errgroup.WithContext(context.Background()) 26 | for _, host := range strings.Split(*hosts, ";") { 27 | host := host 28 | if host == "" { 29 | continue 30 | } 31 | if !strings.HasPrefix(host, "http") { 32 | host = "http://" + host 33 | } 34 | g.Go(func() error { 35 | return NewMonitor(host).LoopHealthCheck(ctx) 36 | }) 37 | time.Sleep(time.Millisecond * 100) 38 | } 39 | g.Wait() 40 | } 41 | -------------------------------------------------------------------------------- /_examples/nodemonitor/monitor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type ( 12 | Monitor struct { 13 | host string 14 | client *mixinnet.Client 15 | 16 | time time.Time 17 | work uint64 18 | topology uint64 19 | warnedAt int64 20 | } 21 | ) 22 | 23 | func NewMonitor(host string) *Monitor { 24 | return &Monitor{ 25 | host: host, 26 | client: mixinnet.NewClient(mixinnet.Config{Safe: true, Hosts: []string{host}}), 27 | } 28 | } 29 | 30 | func (m *Monitor) LoopHealthCheck(ctx context.Context) error { 31 | ctx = m.client.WithHost(ctx, m.host) 32 | sleepDur := time.Millisecond 33 | 34 | for { 35 | select { 36 | case <-ctx.Done(): 37 | return ctx.Err() 38 | 39 | case <-time.After(sleepDur): 40 | if err := m.healthCheck(ctx); err != nil { 41 | sleepDur = time.Second 42 | continue 43 | } 44 | 45 | sleepDur = time.Second * 120 46 | } 47 | } 48 | } 49 | 50 | func (m *Monitor) healthCheck(ctx context.Context) error { 51 | log := logrus.WithFields(logrus.Fields{ 52 | "host": m.host, 53 | }) 54 | 55 | info, err := m.client.ReadConsensusInfo(ctx) 56 | if err != nil { 57 | log.WithError(err).Info("ReadConsensusInfo failed") 58 | return err 59 | } 60 | 61 | for _, node := range info.Graph.Consensus { 62 | if node.Node != info.Node { 63 | continue 64 | } 65 | 66 | var ( 67 | t time.Time 68 | cacheSnapshotCount int 69 | round uint64 70 | work = node.Works[0]*12 + node.Works[1]*10 71 | now = time.Now() 72 | ) 73 | 74 | if cache, ok := info.Graph.Cache[node.Node.String()]; ok && len(cache.Snapshots) > 0 { 75 | t = time.Unix(0, cache.Timestamp) 76 | cacheSnapshotCount = len(cache.Snapshots) 77 | round = cache.Round 78 | } else if final, ok := info.Graph.Final[node.Node.String()]; ok { 79 | t = time.Unix(0, final.End) 80 | round = final.Round 81 | } 82 | 83 | log := log.WithFields(logrus.Fields{ 84 | "node": info.Node, 85 | "version": info.Version, 86 | "topology": info.Graph.Topology, 87 | "topology.pre": m.topology, 88 | "cache.snapshots": cacheSnapshotCount, 89 | "round": round, 90 | "works": work, 91 | "work.pre": m.work, 92 | "works.diff": work - m.work, 93 | "info.time": info.Timestamp, 94 | "time": t, 95 | "time.pre": m.time, 96 | }) 97 | 98 | if !t.After(m.time) { 99 | if now.UnixNano()-m.warnedAt > int64(600*time.Second) { 100 | log.Infof("(%s) not working for %v", m.host, time.Since(t)) 101 | m.warnedAt = now.UnixNano() 102 | } 103 | continue 104 | } 105 | 106 | if m.warnedAt > 0 { 107 | log.Infof("(%s) back to work after %v", m.host, info.Timestamp.Sub(m.time)) 108 | } 109 | m.warnedAt = 0 110 | m.work = work 111 | m.time = t 112 | m.topology = info.Graph.Topology 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /_examples/oauth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2" 9 | ) 10 | 11 | var ( 12 | clientID = flag.String("client", "", "client id") 13 | clientSecret = flag.String("secret", "", "client secret") 14 | code = flag.String("code", "", "oauth code") 15 | 16 | ctx = context.Background() 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | 22 | token, scope, err := mixin.AuthorizeToken(ctx, *clientID, *clientSecret, *code, "") 23 | if err != nil { 24 | log.Printf("AuthorizeToken: %v", err) 25 | return 26 | } 27 | 28 | log.Println("scope", scope) 29 | 30 | user, err := mixin.UserMe(ctx, token) 31 | if err != nil { 32 | log.Printf("UserMe: %v", err) 33 | return 34 | } 35 | 36 | log.Println("user", user.UserID) 37 | } 38 | -------------------------------------------------------------------------------- /_examples/oauth_ed25519/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2" 9 | ) 10 | 11 | var ( 12 | clientID = flag.String("client", "", "client id") 13 | clientSecret = flag.String("secret", "", "client secret") 14 | code = flag.String("code", "", "oauth code") 15 | 16 | ctx = context.Background() 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | 22 | key := mixin.GenerateEd25519Key() 23 | store, err := mixin.AuthorizeEd25519(ctx, *clientID, *clientSecret, *code, "", key) 24 | if err != nil { 25 | log.Printf("AuthorizeEd25519: %v", err) 26 | return 27 | } 28 | 29 | client, err := mixin.NewFromOauthKeystore(store) 30 | if err != nil { 31 | log.Panicln(err) 32 | } 33 | 34 | user, err := client.UserMe(ctx) 35 | if err != nil { 36 | log.Printf("UserMe: %v", err) 37 | return 38 | } 39 | 40 | log.Println("user", user.UserID) 41 | } 42 | -------------------------------------------------------------------------------- /_examples/wallet/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "encoding/json" 9 | "flag" 10 | "log" 11 | "os" 12 | "time" 13 | 14 | "github.com/fox-one/mixin-sdk-go/v2" 15 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 16 | "github.com/shopspring/decimal" 17 | ) 18 | 19 | const ( 20 | ASSET_CNB = "965e5c6e-434c-3fa9-b780-c50f43cd955c" 21 | ) 22 | 23 | var ( 24 | config = flag.String("config", "", "keystore file path") 25 | pin = flag.String("pin", "", "pin") 26 | 27 | ctx = context.Background() 28 | ) 29 | 30 | func main() { 31 | flag.Parse() 32 | 33 | f, err := os.Open(*config) 34 | if err != nil { 35 | log.Panicln(err) 36 | } 37 | 38 | var store mixin.Keystore 39 | if err := json.NewDecoder(f).Decode(&store); err != nil { 40 | log.Panicln(err) 41 | } 42 | 43 | client, err := mixin.NewFromKeystore(&store) 44 | if err != nil { 45 | log.Panicln(err) 46 | } 47 | 48 | if _, err := client.UserMe(ctx); err != nil { 49 | log.Printf("UserMe: %v", err) 50 | return 51 | } 52 | 53 | if err := client.VerifyPin(ctx, *pin); err != nil { 54 | log.Printf("VerifyPin: %v", err) 55 | return 56 | } 57 | 58 | { 59 | createAndTestUser(ctx, client, mixinnet.GenerateKey(rand.Reader).String()) 60 | } 61 | { 62 | _, privateKey, _ := ed25519.GenerateKey(rand.Reader) 63 | createAndTestUser(ctx, client, hex.EncodeToString(privateKey)) 64 | } 65 | } 66 | 67 | func createAndTestUser(ctx context.Context, dapp *mixin.Client, userPin string) { 68 | // create sub wallet 69 | // privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) 70 | _, privateKey, _ := ed25519.GenerateKey(rand.Reader) 71 | sub, subStore, err := dapp.CreateUser(ctx, privateKey, "sub user") 72 | if err != nil { 73 | log.Panicf("CreateUser: %v", err) 74 | } 75 | log.Println("create sub user", sub.UserID) 76 | 77 | testTransfer(ctx, dapp, *pin, sub.UserID, decimal.NewFromInt(100)) 78 | 79 | // set pin 80 | newPin := mixin.RandomPin() 81 | subClient, _ := mixin.NewFromKeystore(subStore) 82 | log.Println("try ModifyPin", newPin) 83 | if err := subClient.ModifyPin(ctx, "", newPin); err != nil { 84 | log.Panicf("ModifyPin (%s) failed: %v", newPin, err) 85 | } 86 | 87 | tipPin, err := mixinnet.KeyFromString(userPin) 88 | if err != nil { 89 | log.Panicf("KeyFromString(%s) failed: %v", userPin, err) 90 | } 91 | log.Println("try ModifyPin", userPin, tipPin, tipPin.Public()) 92 | if err := subClient.ModifyPin(ctx, newPin, tipPin.Public().String()); err != nil { 93 | log.Panicf("ModifyPin (%s) failed: %v", tipPin, err) 94 | } 95 | 96 | if err := subClient.VerifyPin(ctx, userPin); err != nil { 97 | log.Panicf("sub user VerifyPin: %v", err) 98 | } 99 | 100 | testTransfer(ctx, subClient, userPin, dapp.ClientID, decimal.NewFromInt(99)) 101 | } 102 | 103 | func testTransfer(ctx context.Context, dapp *mixin.Client, pin, opponent string, amount decimal.Decimal) { 104 | { 105 | input := &mixin.TransferInput{ 106 | AssetID: ASSET_CNB, // CNB 107 | OpponentID: opponent, 108 | Amount: amount, 109 | // THIS IS AN EXAMPLE. 110 | // NEVER USE A RANDOM TRACE ID IN YOU REAL PROJECT. 111 | TraceID: mixin.RandomTraceID(), 112 | Memo: "test", 113 | } 114 | 115 | snapshot, err := dapp.Transfer(ctx, input, pin) 116 | if err != nil { 117 | switch { 118 | case mixin.IsErrorCodes(err, mixin.InsufficientBalance): 119 | log.Println("insufficient balance") 120 | default: 121 | log.Printf("transfer: %v", err) 122 | } 123 | 124 | return 125 | } 126 | 127 | log.Println("transfer done", snapshot.SnapshotID, snapshot.Memo) 128 | log.Println("sleep 5 seconds") 129 | time.Sleep(5 * time.Second) 130 | 131 | transfer, err := dapp.ReadTransfer(ctx, snapshot.TraceID) 132 | if err != nil { 133 | log.Panicf("ReadTransfer: %v", err) 134 | } 135 | 136 | if transfer.SnapshotID != snapshot.SnapshotID { 137 | log.Panicf("expect %v but got %v", snapshot.SnapshotID, transfer.SnapshotID) 138 | } 139 | 140 | if _, err := dapp.ReadSnapshot(ctx, snapshot.SnapshotID); err != nil { 141 | log.Panicf("read snapshot: %v", err) 142 | } 143 | } 144 | 145 | { 146 | input := mixin.CreateAddressInput{ 147 | AssetID: ASSET_CNB, 148 | Destination: "0xe20FE5C04Fa6b044b720F8CA019Cd896881ED13B", 149 | Label: "mixin-sdk-go wallet example test", 150 | } 151 | addr, err := dapp.CreateAddress(ctx, input, pin) 152 | if err != nil { 153 | log.Panicf("create address: %v", err) 154 | } 155 | 156 | winput := mixin.WithdrawInput{ 157 | AddressID: addr.AddressID, 158 | Amount: decimal.New(1, 0), 159 | TraceID: mixin.RandomTraceID(), 160 | Memo: "withdraw test", 161 | } 162 | if _, err := dapp.Withdraw(ctx, winput, pin); err != nil { 163 | log.Panicf("withdraw: %v", err) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /ack.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import "context" 4 | 5 | type AcknowledgementRequest struct { 6 | MessageID string `json:"message_id,omitempty"` 7 | Status string `json:"status,omitempty"` 8 | } 9 | 10 | func (c *Client) SendAcknowledgements(ctx context.Context, requests []*AcknowledgementRequest) error { 11 | if len(requests) == 0 { 12 | return nil 13 | } 14 | 15 | return c.Post(ctx, "/acknowledgements", requests, nil) 16 | } 17 | 18 | func (c *Client) SendAcknowledgement(ctx context.Context, request *AcknowledgementRequest) error { 19 | return c.SendAcknowledgements(ctx, []*AcknowledgementRequest{request}) 20 | } 21 | -------------------------------------------------------------------------------- /address_mix.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/btcsuite/btcutil/base58" 9 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 10 | "github.com/gofrs/uuid/v5" 11 | ) 12 | 13 | const ( 14 | MixAddressPrefix = "MIX" 15 | MixAddressVersion = byte(2) 16 | ) 17 | 18 | type ( 19 | MixAddress struct { 20 | Version byte 21 | Threshold byte 22 | uuidMembers []uuid.UUID 23 | xinMembers []*mixinnet.Address 24 | } 25 | ) 26 | 27 | func RequireNewMixAddress(members []string, threshold byte) *MixAddress { 28 | addr, err := NewMixAddress(members, threshold) 29 | if err != nil { 30 | panic(err) 31 | } 32 | return addr 33 | } 34 | 35 | func RequireNewMainnetMixAddress(members []string, threshold byte) *MixAddress { 36 | addr, err := NewMainnetMixAddress(members, threshold) 37 | if err != nil { 38 | panic(err) 39 | } 40 | return addr 41 | } 42 | 43 | func NewMixAddress(members []string, threshold byte) (*MixAddress, error) { 44 | if len(members) > 255 { 45 | return nil, fmt.Errorf("invlaid member count: %d", len(members)) 46 | } 47 | if int(threshold) == 0 || int(threshold) > len(members) { 48 | return nil, fmt.Errorf("invlaid members threshold: %d", threshold) 49 | } 50 | ma := &MixAddress{ 51 | Version: MixAddressVersion, 52 | Threshold: threshold, 53 | } 54 | for _, s := range members { 55 | u, err := uuid.FromString(s) 56 | if err != nil { 57 | return nil, fmt.Errorf("invalid member (%v): %v", s, err) 58 | } 59 | ma.uuidMembers = append(ma.uuidMembers, u) 60 | } 61 | return ma, nil 62 | } 63 | 64 | func NewMainnetMixAddress(members []string, threshold byte) (*MixAddress, error) { 65 | if len(members) > 255 { 66 | return nil, fmt.Errorf("invlaid member count: %d", len(members)) 67 | } 68 | if int(threshold) == 0 || int(threshold) > len(members) { 69 | return nil, fmt.Errorf("invlaid members threshold: %d", threshold) 70 | } 71 | ma := &MixAddress{ 72 | Version: MixAddressVersion, 73 | Threshold: threshold, 74 | } 75 | for _, s := range members { 76 | a, err := mixinnet.AddressFromString(s) 77 | if err != nil { 78 | return nil, fmt.Errorf("invalid member (%v): %v", s, err) 79 | } 80 | ma.xinMembers = append(ma.xinMembers, &a) 81 | } 82 | return ma, nil 83 | } 84 | 85 | func (ma *MixAddress) Members() []string { 86 | var members []string 87 | if len(ma.uuidMembers) > 0 { 88 | for _, u := range ma.uuidMembers { 89 | members = append(members, u.String()) 90 | } 91 | } else { 92 | for _, a := range ma.xinMembers { 93 | members = append(members, a.String()) 94 | } 95 | } 96 | return members 97 | } 98 | 99 | func (ma *MixAddress) String() string { 100 | payload := []byte{ma.Version, ma.Threshold} 101 | if l := len(ma.uuidMembers); l > 0 { 102 | if l > 255 { 103 | panic(l) 104 | } 105 | payload = append(payload, byte(l)) 106 | for _, u := range ma.uuidMembers { 107 | payload = append(payload, u.Bytes()...) 108 | } 109 | } else { 110 | l := len(ma.xinMembers) 111 | if l > 255 { 112 | panic(l) 113 | } 114 | payload = append(payload, byte(l)) 115 | for _, a := range ma.xinMembers { 116 | payload = append(payload, a.PublicSpendKey[:]...) 117 | payload = append(payload, a.PublicViewKey[:]...) 118 | } 119 | } 120 | 121 | data := append([]byte(MixAddressPrefix), payload...) 122 | checksum := mixinnet.NewHash(data) 123 | payload = append(payload, checksum[:4]...) 124 | return MixAddressPrefix + base58.Encode(payload) 125 | } 126 | 127 | func MixAddressFromString(s string) (*MixAddress, error) { 128 | var ma MixAddress 129 | if !strings.HasPrefix(s, MixAddressPrefix) { 130 | return nil, fmt.Errorf("invalid address prefix %s", s) 131 | } 132 | data := base58.Decode(s[len(MixAddressPrefix):]) 133 | if len(data) < 3+16+4 { 134 | return nil, fmt.Errorf("invalid address length %d", len(data)) 135 | } 136 | payload := data[:len(data)-4] 137 | checksum := mixinnet.NewHash(append([]byte(MixAddressPrefix), payload...)) 138 | if !bytes.Equal(checksum[:4], data[len(data)-4:]) { 139 | return nil, fmt.Errorf("invalid address checksum %x", checksum[:4]) 140 | } 141 | 142 | total := payload[2] 143 | ma.Version = payload[0] 144 | ma.Threshold = payload[1] 145 | if ma.Version != MixAddressVersion { 146 | return nil, fmt.Errorf("invalid address version %d", ma.Version) 147 | } 148 | if ma.Threshold == 0 || ma.Threshold > total || total > 64 { 149 | return nil, fmt.Errorf("invalid address threshold %d/%d", ma.Threshold, total) 150 | } 151 | 152 | mp := payload[3:] 153 | if len(mp) == 16*int(total) { 154 | for i := 0; i < int(total); i++ { 155 | id, err := uuid.FromBytes(mp[i*16 : i*16+16]) 156 | if err != nil { 157 | return nil, fmt.Errorf("invalid uuid member %s", s) 158 | } 159 | ma.uuidMembers = append(ma.uuidMembers, id) 160 | } 161 | } else if len(mp) == 64*int(total) { 162 | for i := 0; i < int(total); i++ { 163 | var a mixinnet.Address 164 | copy(a.PublicSpendKey[:], mp[i*64:i*64+32]) 165 | copy(a.PublicViewKey[:], mp[i*64+32:i*64+64]) 166 | ma.xinMembers = append(ma.xinMembers, &a) 167 | } 168 | } else { 169 | return nil, fmt.Errorf("invalid address members list %s", s) 170 | } 171 | 172 | return &ma, nil 173 | } 174 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type ( 10 | App struct { 11 | UpdatedAt time.Time `json:"updated_at,omitempty"` 12 | AppID string `json:"app_id,omitempty"` 13 | AppNumber string `json:"app_number,omitempty"` 14 | RedirectURI string `json:"redirect_uri,omitempty"` 15 | HomeURI string `json:"home_uri,omitempty"` 16 | Name string `json:"name,omitempty"` 17 | IconURL string `json:"icon_url,omitempty"` 18 | Description string `json:"description,omitempty"` 19 | Capabilities []string `json:"capabilities,omitempty"` 20 | ResourcePatterns []string `json:"resource_patterns,omitempty"` 21 | Category string `json:"category,omitempty"` 22 | CreatorID string `json:"creator_id,omitempty"` 23 | AppSecret string `json:"app_secret,omitempty"` 24 | } 25 | 26 | FavoriteApp struct { 27 | UserID string `json:"user_id,omitempty"` 28 | AppID string `json:"app_id,omitempty"` 29 | CreatedAt time.Time `json:"created_at,omitempty"` 30 | } 31 | ) 32 | 33 | func (c *Client) ReadApp(ctx context.Context, appID string) (*App, error) { 34 | var app App 35 | uri := fmt.Sprintf("/apps/%s", appID) 36 | if err := c.Get(ctx, uri, nil, &app); err != nil { 37 | return nil, err 38 | } 39 | 40 | return &app, nil 41 | } 42 | 43 | type UpdateAppRequest struct { 44 | RedirectURI string `json:"redirect_uri,omitempty"` 45 | HomeURI string `json:"home_uri,omitempty"` 46 | Name string `json:"name,omitempty"` 47 | Description string `json:"description,omitempty"` 48 | IconBase64 string `json:"icon_base64,omitempty"` 49 | SessionSecret string `json:"session_secret,omitempty"` 50 | Category string `json:"category,omitempty"` 51 | Capabilities []string `json:"capabilities,omitempty"` 52 | ResourcePatterns []string `json:"resource_patterns,omitempty"` 53 | } 54 | 55 | func (c *Client) UpdateApp(ctx context.Context, appID string, req UpdateAppRequest) (*App, error) { 56 | var app App 57 | uri := fmt.Sprintf("/apps/%s", appID) 58 | if err := c.Post(ctx, uri, req, &app); err != nil { 59 | return nil, err 60 | } 61 | 62 | return &app, nil 63 | } 64 | 65 | func (c *Client) ReadFavoriteApps(ctx context.Context, userID string) ([]*FavoriteApp, error) { 66 | uri := fmt.Sprintf("/users/%s/apps/favorite", userID) 67 | 68 | var apps []*FavoriteApp 69 | if err := c.Get(ctx, uri, nil, &apps); err != nil { 70 | return nil, err 71 | } 72 | 73 | return apps, nil 74 | } 75 | 76 | func (c *Client) FavoriteApp(ctx context.Context, appID string) (*FavoriteApp, error) { 77 | uri := fmt.Sprintf("/apps/%s/favorite", appID) 78 | 79 | var app FavoriteApp 80 | if err := c.Post(ctx, uri, nil, &app); err != nil { 81 | return nil, err 82 | } 83 | 84 | return &app, nil 85 | } 86 | 87 | func (c *Client) UnfavoriteApp(ctx context.Context, appID string) error { 88 | uri := fmt.Sprintf("/apps/%s/unfavorite", appID) 89 | 90 | if err := c.Post(ctx, uri, nil, nil); err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /app_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestUpdateApp(t *testing.T) { 11 | ctx := context.Background() 12 | store := newKeystoreFromEnv(t) 13 | 14 | c, err := NewFromKeystore(&store.Keystore) 15 | require.Nil(t, err, "init client") 16 | 17 | app, err := c.ReadApp(ctx, store.ClientID) 18 | require.Nil(t, err, "read app") 19 | t.Log("old name", app.Name) 20 | 21 | name := app.Name 22 | newName := "new name" 23 | 24 | req := UpdateAppRequest{Name: newName} 25 | newApp, err := c.UpdateApp(ctx, app.AppID, req) 26 | require.Nil(t, err, "update app") 27 | t.Log("new name", newApp.Name) 28 | require.Equal(t, newApp.Name, newName, "name should changed") 29 | 30 | // restore name 31 | req.Name = name 32 | _, err = c.UpdateApp(ctx, app.AppID, req) 33 | require.Nil(t, err, "update app") 34 | } 35 | -------------------------------------------------------------------------------- /assets_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestReadAsset(t *testing.T) { 11 | ctx := context.Background() 12 | store := newKeystoreFromEnv(t) 13 | 14 | c, err := NewFromKeystore(&store.Keystore) 15 | require.Nil(t, err, "init client") 16 | 17 | asset, err := c.ReadAsset(ctx, "c6d0c728-2624-429b-8e0d-d9d19b6592fa") 18 | require.Nil(t, err, "read asset") 19 | require.True(t, asset.DepositEntries != nil && len(asset.DepositEntries) > 0, "bitcoin missing segwit address") 20 | } 21 | -------------------------------------------------------------------------------- /attachment.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strconv" 11 | ) 12 | 13 | type Attachment struct { 14 | AttachmentID string `json:"attachment_id"` 15 | UploadURL string `json:"upload_url"` 16 | ViewURL string `json:"view_url"` 17 | } 18 | 19 | func (c *Client) CreateAttachment(ctx context.Context) (*Attachment, error) { 20 | var attachment Attachment 21 | if err := c.Post(ctx, "/attachments", nil, &attachment); err != nil { 22 | return nil, err 23 | } 24 | 25 | return &attachment, nil 26 | } 27 | 28 | func (c *Client) ShowAttachment(ctx context.Context, id string) (*Attachment, error) { 29 | uri := fmt.Sprintf("/attachments/%s", id) 30 | 31 | var attachment Attachment 32 | if err := c.Get(ctx, uri, nil, &attachment); err != nil { 33 | return nil, err 34 | } 35 | 36 | return &attachment, nil 37 | } 38 | 39 | var uploadClient = &http.Client{} 40 | 41 | func UploadAttachmentTo(ctx context.Context, uploadURL string, file []byte) error { 42 | req, err := http.NewRequest("PUT", uploadURL, bytes.NewReader(file)) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | req.Header.Add("Content-Type", "application/octet-stream") 48 | req.Header.Add("x-amz-acl", "public-read") 49 | req.Header.Add("Content-Length", strconv.Itoa(len(file))) 50 | 51 | resp, err := uploadClient.Do(req) 52 | if resp != nil { 53 | _, _ = io.Copy(io.Discard, resp.Body) 54 | _ = resp.Body.Close() 55 | } 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if resp.StatusCode >= 300 { 62 | return errors.New(resp.Status) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func UploadAttachment(ctx context.Context, attachment *Attachment, file []byte) error { 69 | return UploadAttachmentTo(ctx, attachment.UploadURL, file) 70 | } 71 | -------------------------------------------------------------------------------- /attachment_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestUploadAttachment(t *testing.T) { 13 | store := newKeystoreFromEnv(t) 14 | c, err := NewFromKeystore(&store.Keystore) 15 | require.Nil(t, err, "init client from keystore") 16 | 17 | ctx := context.Background() 18 | attachment, err := c.CreateAttachment(ctx) 19 | require.Nil(t, err, "create attachment") 20 | 21 | data := make([]byte, 128) 22 | _, _ = rand.Read(data) 23 | 24 | err = UploadAttachment(ctx, attachment, data) 25 | assert.Nil(t, err, "upload attachment") 26 | } 27 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-resty/resty/v2" 7 | ) 8 | 9 | const ( 10 | ScopeProfileRead = "PROFILE:READ" 11 | ScopePhoneRead = "PHONE:READ" 12 | ScopeContactRead = "CONTACTS:READ" 13 | ScopeAssetsRead = "ASSETS:READ" 14 | ScopeSnapshotsRead = "SNAPSHOTS:READ" 15 | 16 | ScopeFull = "FULL" 17 | ) 18 | 19 | type Signer interface { 20 | SignToken(signature, requestID string, exp time.Duration) string 21 | EncryptPin(pin string) string 22 | } 23 | 24 | type Verifier interface { 25 | Verify(resp *resty.Response) error 26 | } 27 | 28 | type nopVerifier struct{} 29 | 30 | func (nopVerifier) Verify(_ *resty.Response) error { 31 | return nil 32 | } 33 | 34 | func NopVerifier() Verifier { 35 | return nopVerifier{} 36 | } 37 | -------------------------------------------------------------------------------- /authorization.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/json" 9 | "strings" 10 | "time" 11 | 12 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | type Authorization struct { 17 | CreatedAt time.Time `json:"created_at"` 18 | AccessedAt time.Time `json:"accessed_at"` 19 | AuthorizationID string `json:"authorization_id"` 20 | AuthorizationCode string `json:"authorization_code"` 21 | Scopes []string `json:"scopes"` 22 | CodeID string `json:"code_id"` 23 | App App `json:"app"` 24 | User User `json:"user"` 25 | } 26 | 27 | func (c *Client) Authorize(ctx context.Context, authorizationID string, scopes []string, pin string) (*Authorization, error) { 28 | if key, err := mixinnet.KeyFromString(pin); err == nil { 29 | pin = c.EncryptTipPin( 30 | key, 31 | TIPOAuthApprove, 32 | authorizationID, 33 | ) 34 | } 35 | 36 | body := map[string]interface{}{ 37 | "authorization_id": authorizationID, 38 | "scopes": scopes, 39 | "pin_base64": c.EncryptPin(pin), 40 | } 41 | 42 | var authorization Authorization 43 | if err := c.Post(ctx, "/oauth/authorize", body, &authorization); err != nil { 44 | return nil, err 45 | } 46 | 47 | return &authorization, nil 48 | } 49 | 50 | func RequestAuthorization(ctx context.Context, clientID string, scopes []string, challenge string) (*Authorization, error) { 51 | dialer := &websocket.Dialer{ 52 | Subprotocols: []string{"Mixin-OAuth-1"}, 53 | ReadBufferSize: 1024, 54 | } 55 | 56 | conn, _, err := dialer.Dial(blazeURL, nil) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | defer conn.Close() 62 | 63 | if err := writeMessage(conn, "REFRESH_OAUTH_CODE", map[string]interface{}{ 64 | "client_id": clientID, 65 | "scope": strings.Join(scopes, " "), 66 | "code_challenge": challenge, 67 | }); err != nil { 68 | return nil, err 69 | } 70 | 71 | _ = conn.SetReadDeadline(time.Now().Add(pongWait)) 72 | _, r, err := conn.NextReader() 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | var msg BlazeMessage 78 | if err := parseBlazeMessage(r, &msg); err != nil { 79 | return nil, err 80 | } 81 | 82 | if msg.Error != nil { 83 | return nil, msg.Error 84 | } 85 | 86 | var auth Authorization 87 | if err := json.Unmarshal(msg.Data, &auth); err != nil { 88 | return nil, err 89 | } 90 | 91 | return &auth, nil 92 | } 93 | 94 | func CodeChallenge(b []byte) (verifier, challange string) { 95 | verifier = base64.RawURLEncoding.EncodeToString(b) 96 | h := sha256.New() 97 | h.Write(b) 98 | challange = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 99 | return 100 | } 101 | 102 | func RandomCodeChallenge() (verifier, challange string) { 103 | b := make([]byte, 32) 104 | _, _ = rand.Read(b) 105 | return CodeChallenge(b) 106 | } 107 | -------------------------------------------------------------------------------- /blaze_ack.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "container/list" 5 | "context" 6 | "sync" 7 | "time" 8 | 9 | "golang.org/x/sync/errgroup" 10 | "golang.org/x/sync/semaphore" 11 | ) 12 | 13 | type AckQueue struct { 14 | list list.List 15 | mux sync.Mutex 16 | } 17 | 18 | func (q *AckQueue) pushBack(requests ...*AcknowledgementRequest) { 19 | q.mux.Lock() 20 | for _, req := range requests { 21 | q.list.PushBack(req) 22 | } 23 | q.mux.Unlock() 24 | } 25 | 26 | func (q *AckQueue) pushFront(requests ...*AcknowledgementRequest) { 27 | q.mux.Lock() 28 | for _, req := range requests { 29 | q.list.PushFront(req) 30 | } 31 | q.mux.Unlock() 32 | } 33 | 34 | func (q *AckQueue) pull(limit int) []*AcknowledgementRequest { 35 | q.mux.Lock() 36 | 37 | if limit > q.list.Len() { 38 | limit = q.list.Len() 39 | } 40 | 41 | ids := make([]*AcknowledgementRequest, 0, limit) 42 | for i := 0; i < limit; i++ { 43 | e := q.list.Front() 44 | ids = append(ids, e.Value.(*AcknowledgementRequest)) 45 | q.list.Remove(e) 46 | } 47 | 48 | q.mux.Unlock() 49 | return ids 50 | } 51 | 52 | type blazeHandler struct { 53 | *Client 54 | queue AckQueue 55 | } 56 | 57 | func (b *blazeHandler) ack(ctx context.Context) error { 58 | var ( 59 | g errgroup.Group 60 | sem = semaphore.NewWeighted(5) 61 | dur = time.Second 62 | ) 63 | 64 | for { 65 | select { 66 | case <-ctx.Done(): 67 | return g.Wait() 68 | case <-time.After(dur): 69 | requests := b.queue.pull(ackBatch) 70 | if len(requests) < ackBatch { 71 | dur = time.Second 72 | } else { 73 | dur = 200 * time.Millisecond 74 | } 75 | 76 | if len(requests) > 0 { 77 | if !sem.TryAcquire(1) { 78 | b.queue.pushFront(requests...) 79 | break 80 | } 81 | 82 | g.Go(func() error { 83 | defer sem.Release(1) 84 | 85 | err := b.SendAcknowledgements(ctx, requests) 86 | if err != nil { 87 | b.queue.pushFront(requests...) 88 | } 89 | 90 | return err 91 | }) 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /blaze_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestClient_LoopBlaze(t *testing.T) { 10 | store := newKeystoreFromEnv(t) 11 | c, err := NewFromKeystore(&store.Keystore) 12 | if err != nil { 13 | t.Error(err) 14 | t.FailNow() 15 | } 16 | 17 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 18 | defer cancel() 19 | 20 | err = c.LoopBlaze(ctx, BlazeListenFunc(func(ctx context.Context, msg *MessageView, userID string) error { 21 | t.Log(msg.Category, msg.Data) 22 | return nil 23 | })) 24 | 25 | t.Log(err) 26 | } 27 | -------------------------------------------------------------------------------- /circle.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | const ( 11 | CircleActionAdd = "ADD" 12 | CircleActionRemove = "REMOVE" 13 | 14 | CircleItemTypeUsers = "users" 15 | CircleItemTypeConversations = "conversations" 16 | ) 17 | 18 | type Circle struct { 19 | ID string `json:"circle_id,omitempty"` 20 | Name string `json:"name,omitempty"` 21 | UserID string `json:"user_id,omitempty"` 22 | CreatedAt time.Time `json:"created_at,omitempty"` 23 | } 24 | 25 | func (c *Client) ListCircles(ctx context.Context) ([]*Circle, error) { 26 | var circles []*Circle 27 | if err := c.Get(ctx, "/circles", nil, &circles); err != nil { 28 | return nil, err 29 | } 30 | 31 | return circles, nil 32 | } 33 | 34 | func (c *Client) ReadCircle(ctx context.Context, circleID string) (*Circle, error) { 35 | var circle Circle 36 | if err := c.Get(ctx, "/circles/"+circleID, nil, &circle); err != nil { 37 | return nil, err 38 | } 39 | 40 | return &circle, nil 41 | } 42 | 43 | type CreateCircleParams struct { 44 | Name string `json:"name,omitempty"` 45 | } 46 | 47 | func (c *Client) CreateCircle(ctx context.Context, args CreateCircleParams) (*Circle, error) { 48 | var circle Circle 49 | if err := c.Post(ctx, "/circles", args, &circle); err != nil { 50 | return nil, err 51 | } 52 | 53 | return &circle, nil 54 | } 55 | 56 | type UpdateCircleParams struct { 57 | CircleID string `json:"circle_id,omitempty"` 58 | Name string `json:"name,omitempty"` 59 | } 60 | 61 | func (c *Client) UpdateCircle(ctx context.Context, args UpdateCircleParams) (*Circle, error) { 62 | var circle Circle 63 | body := map[string]interface{}{ 64 | "name": args.Name, 65 | } 66 | 67 | if err := c.Post(ctx, "/circles/"+args.CircleID, body, &circle); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &circle, nil 72 | } 73 | 74 | func (c *Client) DeleteCircle(ctx context.Context, circleID string) error { 75 | uri := fmt.Sprintf("/circles/%s/delete", circleID) 76 | return c.Post(ctx, uri, nil, nil) 77 | } 78 | 79 | type ManageCircleParams struct { 80 | CircleID string `json:"circle_id,omitempty"` 81 | Action string `json:"action,omitempty"` // ADD or REMOVE 82 | ItemType string `json:"item_type,omitempty"` // users or conversations 83 | ItemID string `json:"item_id,omitempty"` // user_id or conversation_id 84 | } 85 | 86 | type CircleItem struct { 87 | CreatedAt time.Time `json:"created_at,omitempty"` 88 | CircleID string `json:"circle_id,omitempty"` 89 | ConversationID string `json:"conversation_id,omitempty"` 90 | UserID string `json:"user_id,omitempty"` 91 | } 92 | 93 | func (c *Client) ManageCircle(ctx context.Context, args ManageCircleParams) (*CircleItem, error) { 94 | var items []*CircleItem 95 | uri := fmt.Sprintf("%s/%s/circles", args.ItemType, args.ItemID) 96 | body := []interface{}{map[string]interface{}{ 97 | "action": args.Action, 98 | "circle_id": args.CircleID, 99 | }} 100 | 101 | if err := c.Post(ctx, uri, body, &items); err != nil { 102 | return nil, err 103 | } 104 | 105 | return items[0], nil 106 | } 107 | 108 | type ListCircleItemsParams struct { 109 | CircleID string `json:"circle_id,omitempty"` 110 | Offset time.Time `json:"offset,omitempty"` 111 | Limit int `json:"limit,omitempty"` 112 | } 113 | 114 | func (c *Client) ListCircleItems(ctx context.Context, args ListCircleItemsParams) ([]*CircleItem, error) { 115 | var items []*CircleItem 116 | uri := fmt.Sprintf("/circles/%s/conversations", args.CircleID) 117 | params := map[string]string{ 118 | "offset": args.Offset.Format(time.RFC3339Nano), 119 | "limit": strconv.Itoa(args.Limit), 120 | } 121 | 122 | if err := c.Get(ctx, uri, params, &items); err != nil { 123 | return nil, err 124 | } 125 | 126 | return items, nil 127 | } 128 | -------------------------------------------------------------------------------- /circle_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestCircle(t *testing.T) { 12 | store := newKeystoreFromEnv(t) 13 | c, err := NewFromKeystore(&store.Keystore) 14 | if err != nil { 15 | t.Error(err) 16 | t.FailNow() 17 | } 18 | 19 | ctx := context.Background() 20 | name := RandomPin() 21 | 22 | circle, err := c.CreateCircle(ctx, CreateCircleParams{ 23 | Name: name, 24 | }) 25 | 26 | require.NoError(t, err) 27 | assert.Equal(t, circle.Name, name) 28 | 29 | t.Run("read circle", func(t *testing.T) { 30 | c, err := c.ReadCircle(ctx, circle.ID) 31 | require.NoError(t, err) 32 | 33 | assert.Equal(t, c.Name, circle.Name) 34 | }) 35 | 36 | t.Run("update circle", func(t *testing.T) { 37 | newName := RandomPin() 38 | c, err := c.UpdateCircle(ctx, UpdateCircleParams{ 39 | CircleID: circle.ID, 40 | Name: newName, 41 | }) 42 | 43 | require.NoError(t, err) 44 | assert.Equal(t, c.Name, newName) 45 | }) 46 | 47 | t.Run("add user", func(t *testing.T) { 48 | app, err := c.ReadApp(ctx, c.ClientID) 49 | require.NoError(t, err) 50 | 51 | item, err := c.ManageCircle(ctx, ManageCircleParams{ 52 | CircleID: circle.ID, 53 | Action: CircleActionAdd, 54 | ItemType: CircleItemTypeUsers, 55 | ItemID: app.CreatorID, 56 | }) 57 | require.NoError(t, err) 58 | assert.Equal(t, item.UserID, app.CreatorID) 59 | }) 60 | 61 | t.Run("list items", func(t *testing.T) { 62 | items, err := c.ListCircleItems(ctx, ListCircleItemsParams{ 63 | CircleID: circle.ID, 64 | Limit: 10, 65 | }) 66 | require.NoError(t, err) 67 | require.Len(t, items, 1) 68 | }) 69 | 70 | t.Run("delete circle", func(t *testing.T) { 71 | err := c.DeleteCircle(ctx, circle.ID) 72 | require.NoError(t, err) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | 7 | "github.com/go-resty/resty/v2" 8 | ) 9 | 10 | type Client struct { 11 | Signer 12 | Verifier 13 | MessageLocker 14 | 15 | ClientID string 16 | } 17 | 18 | func newClient(id string) *Client { 19 | return &Client{ 20 | ClientID: id, 21 | Verifier: NopVerifier(), 22 | MessageLocker: &messageLockNotSupported{}, 23 | } 24 | } 25 | 26 | func NewFromKeystore(keystore *Keystore) (*Client, error) { 27 | auth, err := AuthFromKeystore(keystore) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | c := newClient(keystore.ClientID) 33 | c.Signer = auth 34 | 35 | if key, ok := auth.signKey.(ed25519.PrivateKey); ok { 36 | c.MessageLocker = &ed25519MessageLocker{ 37 | sessionID: keystore.SessionID, 38 | key: key, 39 | } 40 | } 41 | 42 | return c, nil 43 | } 44 | 45 | func NewFromAccessToken(accessToken string) *Client { 46 | c := newClient("") 47 | c.Signer = accessTokenAuth(accessToken) 48 | 49 | return c 50 | } 51 | 52 | func NewFromOauthKeystore(keystore *OauthKeystore) (*Client, error) { 53 | c := newClient(keystore.ClientID) 54 | 55 | auth, err := AuthFromOauthKeystore(keystore) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | c.Signer = auth 61 | c.Verifier = auth 62 | 63 | return c, nil 64 | } 65 | 66 | func (c *Client) Request(ctx context.Context) *resty.Request { 67 | ctx = WithVerifier(ctx, c.Verifier) 68 | ctx = WithSigner(ctx, c.Signer) 69 | return Request(ctx) 70 | } 71 | 72 | func (c *Client) Get(ctx context.Context, uri string, params map[string]string, resp interface{}) error { 73 | r, err := c.Request(ctx).SetQueryParams(params).Get(uri) 74 | if err != nil { 75 | if requestID := extractRequestID(r); requestID != "" { 76 | return WrapErrWithRequestID(err, requestID) 77 | } 78 | 79 | return err 80 | } 81 | 82 | return UnmarshalResponse(r, resp) 83 | } 84 | 85 | func (c *Client) Post(ctx context.Context, uri string, body interface{}, resp interface{}) error { 86 | r, err := c.Request(ctx).SetBody(body).Post(uri) 87 | if err != nil { 88 | if requestID := extractRequestID(r); requestID != "" { 89 | return WrapErrWithRequestID(err, requestID) 90 | } 91 | 92 | return err 93 | } 94 | 95 | return UnmarshalResponse(r, resp) 96 | } 97 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFixDecodeEd25519Key(t *testing.T) { 11 | const emptyPrivateKey = "" 12 | b, err := ed25519Encoding.DecodeString(emptyPrivateKey) 13 | assert.Nil(t, err, "decode empty string success") 14 | assert.False(t, len(b) == ed25519.PrivateKeySize) 15 | } 16 | -------------------------------------------------------------------------------- /code.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type CodeType string 10 | 11 | const ( 12 | TypeUser CodeType = "user" 13 | TypeConversation CodeType = "conversation" 14 | TypePayment CodeType = "payment" 15 | TypeMultisig CodeType = "multisig_request" 16 | TypeCollectible CodeType = "non_fungible_request" 17 | TypeAuthorization CodeType = "authorization" 18 | ) 19 | 20 | type Code struct { 21 | Type CodeType `json:"type"` 22 | RawData json.RawMessage 23 | } 24 | 25 | func (c *Code) User() *User { 26 | if c.Type != TypeUser { 27 | return nil 28 | } 29 | var user User 30 | if err := json.Unmarshal(c.RawData, &user); err != nil { 31 | return nil 32 | } 33 | return &user 34 | } 35 | 36 | func (c *Code) Conversation() *Conversation { 37 | if c.Type != TypeConversation { 38 | return nil 39 | } 40 | var conversation Conversation 41 | if err := json.Unmarshal(c.RawData, &conversation); err != nil { 42 | return nil 43 | } 44 | return &conversation 45 | } 46 | 47 | func (c *Code) Payment() *Payment { 48 | if c.Type != TypePayment { 49 | return nil 50 | } 51 | var payment Payment 52 | if err := json.Unmarshal(c.RawData, &payment); err != nil { 53 | return nil 54 | } 55 | return &payment 56 | } 57 | 58 | func (c *Code) Multisig() *MultisigRequest { 59 | if c.Type != TypeMultisig { 60 | return nil 61 | } 62 | var multisig MultisigRequest 63 | if err := json.Unmarshal(c.RawData, &multisig); err != nil { 64 | return nil 65 | } 66 | return &multisig 67 | } 68 | 69 | func (c *Code) Collectible() *CollectibleRequest { 70 | if c.Type != TypeCollectible { 71 | return nil 72 | } 73 | var collectible CollectibleRequest 74 | if err := json.Unmarshal(c.RawData, &collectible); err != nil { 75 | return nil 76 | } 77 | return &collectible 78 | } 79 | 80 | func (c *Code) Authorization() *Authorization { 81 | if c.Type != TypeAuthorization { 82 | return nil 83 | } 84 | var authorization Authorization 85 | if err := json.Unmarshal(c.RawData, &authorization); err != nil { 86 | return nil 87 | } 88 | return &authorization 89 | } 90 | 91 | func (c *Client) GetCode(ctx context.Context, codeString string) (*Code, error) { 92 | uri := fmt.Sprintf("/codes/%s", codeString) 93 | var data json.RawMessage 94 | if err := c.Get(ctx, uri, nil, &data); err != nil { 95 | return nil, err 96 | } 97 | 98 | var code Code 99 | if err := json.Unmarshal(data, &code); err != nil { 100 | return nil, err 101 | } 102 | code.RawData = data 103 | return &code, nil 104 | } 105 | -------------------------------------------------------------------------------- /code_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetCode(t *testing.T) { 11 | ctx := context.Background() 12 | store := newKeystoreFromEnv(t) 13 | 14 | c, err := NewFromKeystore(&store.Keystore) 15 | require.Nil(t, err, "init client") 16 | 17 | code, err := c.GetCode(ctx, "c76310d8-c563-499e-9866-c61ae2cbee11") 18 | require.Nil(t, err, "get code") 19 | require.True(t, code.Type == TypePayment) 20 | payment := code.Payment() 21 | require.True(t, payment.Amount == "1") 22 | 23 | code, err = c.GetCode(ctx, "d4b174c2-2691-4289-b4a0-2d0f9ec43618") 24 | require.Nil(t, err, "get code") 25 | require.True(t, code.Type == TypePayment) 26 | payment = code.Payment() 27 | require.True(t, payment.Amount == "1") 28 | 29 | code, err = c.GetCode(ctx, "e0a53283-da39-438c-ba94-8d77071e9860") 30 | require.Nil(t, err, "get code") 31 | require.True(t, code.Type == TypeConversation) 32 | conversation := code.Conversation() 33 | require.True(t, conversation.Category == "GROUP") 34 | } 35 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type contextKey int 8 | 9 | const ( 10 | _ contextKey = iota 11 | signerKey 12 | verifierKey 13 | requestIdKey 14 | mixinnetHostKey 15 | ) 16 | 17 | func WithSigner(ctx context.Context, s Signer) context.Context { 18 | return context.WithValue(ctx, signerKey, s) 19 | } 20 | 21 | func WithVerifier(ctx context.Context, v Verifier) context.Context { 22 | return context.WithValue(ctx, verifierKey, v) 23 | } 24 | 25 | // WithRequestID bind request id to context 26 | // request id must be uuid 27 | func WithRequestID(ctx context.Context, requestID string) context.Context { 28 | return context.WithValue(ctx, requestIdKey, requestID) 29 | } 30 | 31 | var newRequestID = newUUID 32 | 33 | func RequestIdFromContext(ctx context.Context) string { 34 | if v, ok := ctx.Value(requestIdKey).(string); ok { 35 | return v 36 | } 37 | 38 | return newRequestID() 39 | } 40 | -------------------------------------------------------------------------------- /conversation_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestConversation(t *testing.T) { 13 | ctx := context.Background() 14 | store := newKeystoreFromEnv(t) 15 | 16 | c, err := NewFromKeystore(&store.Keystore) 17 | require.Nil(t, err, "init client") 18 | 19 | me, err := c.UserMe(ctx) 20 | require.Nil(t, err, "UserMe") 21 | 22 | if me.App == nil { 23 | t.SkipNow() 24 | } 25 | 26 | id := newUUID() 27 | 28 | t.Run("create group conversation", func(t *testing.T) { 29 | conversation, err := c.CreateGroupConversation(ctx, id, "group", []*Participant{ 30 | { 31 | UserID: me.App.CreatorID, 32 | }, 33 | }) 34 | 35 | require.Nil(t, err, "create conversation") 36 | assert.Equal(t, id, conversation.ConversationID, "check conversation id") 37 | }) 38 | 39 | t.Run("send message", func(t *testing.T) { 40 | req := &MessageRequest{ 41 | ConversationID: id, 42 | MessageID: newUUID(), 43 | Category: MessageCategoryPlainText, 44 | Data: base64.StdEncoding.EncodeToString([]byte("hello mixin-sdk-go")), 45 | } 46 | 47 | require.Nil(t, c.SendMessage(ctx, req), "send message") 48 | }) 49 | 50 | t.Run("send messages", func(t *testing.T) { 51 | var requests []*MessageRequest 52 | 53 | requests = append(requests, &MessageRequest{ 54 | ConversationID: id, 55 | MessageID: newUUID(), 56 | RecipientID: me.App.CreatorID, 57 | Category: MessageCategoryPlainText, 58 | Data: base64.StdEncoding.EncodeToString([]byte("1")), 59 | }) 60 | 61 | requests = append(requests, &MessageRequest{ 62 | ConversationID: id, 63 | MessageID: newUUID(), 64 | RecipientID: me.App.CreatorID, 65 | Category: MessageCategoryPlainText, 66 | Data: base64.StdEncoding.EncodeToString([]byte("2")), 67 | }) 68 | 69 | require.Nil(t, c.SendMessages(ctx, requests), "send messages") 70 | }) 71 | 72 | t.Run("admin participant", func(t *testing.T) { 73 | _, err := c.AdminParticipants(ctx, id, me.App.CreatorID) 74 | require.Nil(t, err, "admin participant") 75 | }) 76 | 77 | t.Run("remove participant", func(t *testing.T) { 78 | _, err := c.RemoveParticipants(ctx, id, me.App.CreatorID) 79 | require.Nil(t, err, "remove participant") 80 | }) 81 | 82 | t.Run("add participant", func(t *testing.T) { 83 | _, err := c.AddParticipants(ctx, id, me.App.CreatorID) 84 | require.Nil(t, err, "add participant") 85 | }) 86 | 87 | t.Run("rotate conversation", func(t *testing.T) { 88 | conversation, err := c.ReadConversation(ctx, id) 89 | require.Nil(t, err, "read conversation") 90 | 91 | rotated, err := c.RotateConversation(ctx, id) 92 | require.Nil(t, err, "rotate conversation") 93 | 94 | assert.NotEqual(t, conversation.CodeURL, rotated.CodeURL, "code url should changed") 95 | }) 96 | 97 | t.Run("update announcement", func(t *testing.T) { 98 | conversation, err := c.ReadConversation(ctx, id) 99 | require.Nil(t, err, "read conversation") 100 | newAnnouncement := conversation.Announcement + " new" 101 | 102 | updated, err := c.UpdateConversationAnnouncement(ctx, id, "test") 103 | require.Nil(t, err, "update conversation") 104 | 105 | assert.Equal(t, newAnnouncement, updated.Announcement, "announcement should changed") 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /curve.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "crypto/sha512" 6 | 7 | "filippo.io/edwards25519" 8 | ) 9 | 10 | func privateKeyToCurve25519(curve25519Private *[32]byte, privateKey ed25519.PrivateKey) { 11 | h := sha512.New() 12 | h.Write(privateKey.Seed()) 13 | digest := h.Sum(nil) 14 | 15 | digest[0] &= 248 16 | digest[31] &= 127 17 | digest[31] |= 64 18 | 19 | copy(curve25519Private[:], digest) 20 | } 21 | 22 | func publicKeyToCurve25519(publicKey ed25519.PublicKey) ([]byte, error) { 23 | p, err := (&edwards25519.Point{}).SetBytes(publicKey[:]) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return p.BytesMontgomery(), nil 28 | } 29 | -------------------------------------------------------------------------------- /ed25519.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "encoding/base64" 6 | ) 7 | 8 | func GenerateEd25519Key() ed25519.PrivateKey { 9 | _, private, _ := ed25519.GenerateKey(nil) 10 | return private 11 | } 12 | 13 | var ed25519Encoding = &ed25519Encoder{} 14 | 15 | type ed25519Encoder struct{} 16 | 17 | func (enc *ed25519Encoder) EncodeToString(b []byte) string { 18 | return base64.RawURLEncoding.EncodeToString(b) 19 | } 20 | 21 | func (enc *ed25519Encoder) DecodeString(s string) ([]byte, error) { 22 | b, err := base64.RawURLEncoding.DecodeString(s) 23 | if err != nil { 24 | b, err = base64.StdEncoding.DecodeString(s) 25 | } 26 | 27 | return b, err 28 | } 29 | -------------------------------------------------------------------------------- /endpoint.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "os" 7 | "time" 8 | ) 9 | 10 | const ( 11 | DefaultApiHost = "https://api.mixin.one" 12 | DefaultBlazeHost = "blaze.mixin.one" 13 | 14 | ZeromeshApiHost = "https://mixin-api.zeromesh.net" 15 | ZeromeshBlazeHost = "mixin-blaze.zeromesh.net" 16 | 17 | EchoApiHost = "https://echo.yiplee.com" 18 | ) 19 | 20 | func UseApiHost(host string) { 21 | httpClient.HostURL = host 22 | } 23 | 24 | var ( 25 | blazeURL = buildBlazeURL(DefaultBlazeHost) 26 | ) 27 | 28 | func buildBlazeURL(host string) string { 29 | u := url.URL{Scheme: "wss", Host: host} 30 | return u.String() 31 | } 32 | 33 | func UseBlazeHost(host string) { 34 | blazeURL = buildBlazeURL(host) 35 | } 36 | 37 | func UseBlazeURL(rawURL string) { 38 | u, err := url.Parse(rawURL) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | blazeURL = u.String() 44 | } 45 | 46 | func useApi(url string) <-chan string { 47 | r := make(chan string) 48 | go func() { 49 | defer close(r) 50 | _, err := http.Get(url) 51 | if err == nil { 52 | r <- url 53 | } 54 | }() 55 | return r 56 | } 57 | 58 | func timer() <-chan string { 59 | r := make(chan string) 60 | go func() { 61 | defer close(r) 62 | time.Sleep(time.Second * 30) 63 | r <- "" 64 | }() 65 | return r 66 | } 67 | 68 | func UseAutoFasterRoute() { 69 | for { 70 | var r string 71 | select { 72 | case r = <-useApi(DefaultApiHost): 73 | case r = <-useApi(ZeromeshApiHost): 74 | case r = <-timer(): 75 | } 76 | if r == DefaultApiHost { 77 | UseApiHost(DefaultApiHost) 78 | UseBlazeHost(DefaultBlazeHost) 79 | } else if r == ZeromeshApiHost { 80 | UseApiHost(ZeromeshApiHost) 81 | UseBlazeHost(ZeromeshBlazeHost) 82 | } 83 | time.Sleep(time.Minute * 5) 84 | } 85 | } 86 | 87 | func init() { 88 | if _, ok := os.LookupEnv("MIXIN_SDK_USE_ZEROMESH"); ok { 89 | UseApiHost(ZeromeshApiHost) 90 | UseBlazeHost(ZeromeshBlazeHost) 91 | } 92 | 93 | if host, ok := os.LookupEnv("MIXIN_SDK_API_HOST"); ok && host != "" { 94 | UseApiHost(host) 95 | } 96 | 97 | if host, ok := os.LookupEnv("MIXIN_SDK_BLAZE_HOST"); ok && host != "" { 98 | UseBlazeHost(host) 99 | } 100 | 101 | if rawURL, ok := os.LookupEnv("MIXIN_SDK_BLAZE_URL"); ok && rawURL != "" { 102 | UseBlazeURL(rawURL) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // mixin error codes https://developers.mixin.one/api/alpha-mixin-network/errors/ 9 | const ( 10 | Unauthorized = 401 11 | EndpointNotFound = 404 12 | InsufficientBalance = 20117 13 | PinIncorrect = 20119 14 | InsufficientFee = 20124 15 | InvalidTraceID = 20125 16 | InvalidReceivers = 20150 17 | 18 | InvalidOutputKey = 2000001 19 | InputLocked = 2000002 20 | InvalidSignature = 2000003 21 | ) 22 | 23 | type Error struct { 24 | Status int `json:"status"` 25 | Code int `json:"code"` 26 | Description string `json:"description"` 27 | Extra map[string]interface{} `json:"extra,omitempty"` 28 | RequestID string `json:"request_id,omitempty"` 29 | } 30 | 31 | func (e *Error) Error() string { 32 | s := fmt.Sprintf("[%d/%d] %s", e.Status, e.Code, e.Description) 33 | for k, v := range e.Extra { 34 | s += fmt.Sprintf(" %v=%v", k, v) 35 | } 36 | 37 | if e.RequestID != "" { 38 | s += fmt.Sprintf(" id=%s", e.RequestID) 39 | } 40 | 41 | return s 42 | } 43 | 44 | func IsErrorCodes(err error, codes ...int) bool { 45 | var e *Error 46 | if errors.As(err, &e) { 47 | for _, code := range codes { 48 | if e.Code == code { 49 | return true 50 | } 51 | } 52 | } 53 | 54 | return false 55 | } 56 | 57 | func createError(status, code int, description string) error { 58 | return &Error{ 59 | Status: status, 60 | Code: code, 61 | Description: description, 62 | } 63 | } 64 | 65 | // errWithRequestID wrap err with request id 66 | type errWithRequestID struct { 67 | err error 68 | requestID string 69 | } 70 | 71 | func (e *errWithRequestID) Unwrap() error { 72 | return e.err 73 | } 74 | 75 | func (e *errWithRequestID) Error() string { 76 | return fmt.Sprintf("%v id=%s", e.err, e.requestID) 77 | } 78 | 79 | func WrapErrWithRequestID(err error, id string) error { 80 | if e, ok := err.(*Error); ok { 81 | e.RequestID = id 82 | return e 83 | } 84 | 85 | return &errWithRequestID{err: err, requestID: id} 86 | } 87 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsErrorCodes(t *testing.T) { 11 | _, err := UserMe(context.TODO(), "invalid token") 12 | assert.True(t, IsErrorCodes(err, Unauthorized), "error should be %v", Unauthorized) 13 | } 14 | -------------------------------------------------------------------------------- /fiats.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // Fiat is a struct for fiat currencies 10 | type Fiat struct { 11 | Code string `json:"code,omitempty"` 12 | Rate decimal.Decimal `json:"rate,omitempty"` 13 | } 14 | 15 | // ReadFiats returns the exchange rates between two currencies 16 | func (c *Client) ReadFiats(ctx context.Context) ([]Fiat, error) { 17 | var rates []Fiat 18 | if err := c.Get(ctx, "/fiats", nil, &rates); err != nil { 19 | return nil, err 20 | } 21 | 22 | return rates, nil 23 | } 24 | 25 | // ExchangeRate represent the exchange rate between two currencies 26 | // deprecated: use Fiat instead 27 | type ExchangeRate Fiat 28 | 29 | // ReadExchangeRates returns the exchange rates between two currencies 30 | // deprecated: use ReadFiats instead 31 | func (c *Client) ReadExchangeRates(ctx context.Context) ([]ExchangeRate, error) { 32 | fiats, err := c.ReadFiats(ctx) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var rates []ExchangeRate 38 | for _, fiat := range fiats { 39 | rates = append(rates, ExchangeRate(fiat)) 40 | } 41 | 42 | return rates, nil 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fox-one/mixin-sdk-go/v2 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | filippo.io/edwards25519 v1.1.0 7 | github.com/btcsuite/btcutil v1.0.2 8 | github.com/fox-one/msgpack v1.0.0 9 | github.com/go-resty/resty/v2 v2.16.5 10 | github.com/gofrs/uuid/v5 v5.3.2 11 | github.com/golang-jwt/jwt/v5 v5.2.2 12 | github.com/gorilla/websocket v1.5.3 13 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c 14 | github.com/shopspring/decimal v1.4.0 15 | github.com/stretchr/testify v1.10.0 16 | github.com/zeebo/blake3 v0.2.4 17 | golang.org/x/crypto v0.38.0 18 | golang.org/x/sync v0.14.0 19 | ) 20 | 21 | require ( 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/golang/protobuf v1.5.4 // indirect 24 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/vmihailenco/tagparser v0.1.2 // indirect 27 | golang.org/x/net v0.40.0 // indirect 28 | golang.org/x/sys v0.33.0 // indirect 29 | google.golang.org/appengine v1.6.8 // indirect 30 | google.golang.org/protobuf v1.36.6 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /keystore_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "encoding/hex" 8 | "encoding/json" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 14 | "github.com/golang-jwt/jwt/v5" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type ( 20 | SpenderKeystore struct { 21 | Keystore 22 | SpendKey mixinnet.Key `json:"spend_key"` 23 | Pin string `json:"pin"` 24 | } 25 | ) 26 | 27 | func decodeKeystoreAndPinFromEnv(t *testing.T) *SpenderKeystore { 28 | ctx := context.Background() 29 | 30 | env := "TEST_KEYSTORE_PATH" 31 | path := os.Getenv(env) 32 | if path == "" { 33 | t.Logf("skip test, env %s not set", env) 34 | t.SkipNow() 35 | } 36 | 37 | f, err := os.Open(path) 38 | require.Nil(t, err, "open path: %v", path) 39 | 40 | defer f.Close() 41 | 42 | var store SpenderKeystore 43 | require.Nil(t, json.NewDecoder(f).Decode(&store), "decode keystore") 44 | 45 | client, err := NewFromKeystore(&store.Keystore) 46 | require.NoError(t, err, "init client") 47 | 48 | user, err := client.UserMe(ctx) 49 | require.NoError(t, err, "UserMe") 50 | 51 | if store.SpendKey.HasValue() { 52 | store.SpendKey, _ = mixinnet.ParseKeyWithPub(store.SpendKey.String(), user.SpendPublicKey) 53 | } 54 | 55 | if len(store.Pin) > 6 { 56 | pub, err := ed25519Encoding.DecodeString(user.TipKeyBase64) 57 | require.NoError(t, err, "decode tip key") 58 | 59 | pin, _ := mixinnet.ParseKeyWithPub(store.Pin, hex.EncodeToString(pub)) 60 | store.Pin = pin.String() 61 | } 62 | 63 | return &store 64 | } 65 | 66 | func newKeystoreFromEnv(t *testing.T) *SpenderKeystore { 67 | return decodeKeystoreAndPinFromEnv(t) 68 | } 69 | 70 | func TestKeystoreAuth(t *testing.T) { 71 | s := newKeystoreFromEnv(t) 72 | 73 | auth, err := AuthFromKeystore(&s.Keystore) 74 | require.Nil(t, err, "auth from keystore") 75 | 76 | sig := SignRaw("GET", "/me", nil) 77 | token := auth.SignToken(sig, newRequestID(), time.Minute) 78 | 79 | me, err := UserMe(context.TODO(), token) 80 | require.Nil(t, err, "UserMe") 81 | 82 | assert.Equal(t, s.ClientID, me.UserID, "client id should be same") 83 | } 84 | 85 | func TestKeystoreAuth_SignTokenAt(t *testing.T) { 86 | auth := &KeystoreAuth{ 87 | Keystore: &Keystore{ 88 | ClientID: newUUID(), 89 | SessionID: newUUID(), 90 | }, 91 | } 92 | 93 | sig := SignRaw("GET", "/me", nil) 94 | requestID := newUUID() 95 | at := time.Now() 96 | exp := time.Minute 97 | 98 | t.Run("rsa", func(t *testing.T) { 99 | auth.signMethod = jwt.SigningMethodRS512 100 | auth.signKey, _ = rsa.GenerateKey(rand.Reader, 2048) 101 | 102 | assert.Equal( 103 | t, 104 | auth.SignTokenAt(sig, requestID, at, exp), 105 | auth.SignTokenAt(sig, requestID, at, exp), 106 | "token should be the same", 107 | ) 108 | 109 | assert.Equal( 110 | t, 111 | auth.SignTokenAt(sig, requestID, at.Add(time.Hour), exp), 112 | auth.SignTokenAt(sig, requestID, at.Add(time.Hour), exp), 113 | "token should be the same", 114 | ) 115 | }) 116 | 117 | t.Run("ed25519", func(t *testing.T) { 118 | auth.signMethod = jwt.SigningMethodEdDSA 119 | auth.signKey = GenerateEd25519Key() 120 | 121 | assert.Equal( 122 | t, 123 | auth.SignTokenAt(sig, requestID, at, exp), 124 | auth.SignTokenAt(sig, requestID, at, exp), 125 | "token should be the same", 126 | ) 127 | 128 | assert.Equal( 129 | t, 130 | auth.SignTokenAt(sig, requestID, at.Add(time.Hour), exp), 131 | auth.SignTokenAt(sig, requestID, at.Add(time.Hour), exp), 132 | "token should be the same", 133 | ) 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /legacy_address.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type Address struct { 12 | AddressID string `json:"address_id,omitempty"` 13 | AssetID string `json:"asset_id"` 14 | Label string `json:"label,omitempty"` 15 | Destination string `json:"destination,omitempty"` 16 | Tag string `json:"tag,omitempty"` 17 | Fee decimal.Decimal `json:"fee,omitempty"` 18 | Dust decimal.Decimal `json:"dust,omitempty"` 19 | } 20 | 21 | type CreateAddressInput struct { 22 | AssetID string `json:"asset_id"` 23 | Destination string `json:"destination,omitempty"` 24 | Tag string `json:"tag,omitempty"` 25 | Label string `json:"label,omitempty"` 26 | } 27 | 28 | func (c *Client) CreateAddress(ctx context.Context, input CreateAddressInput, pin string) (*Address, error) { 29 | var body interface{} 30 | if key, err := mixinnet.KeyFromString(pin); err == nil { 31 | body = struct { 32 | CreateAddressInput 33 | Pin string `json:"pin_base64,omitempty"` 34 | }{ 35 | CreateAddressInput: input, 36 | Pin: c.EncryptTipPin( 37 | key, 38 | TIPAddressAdd, 39 | input.AssetID, 40 | input.Destination, 41 | input.Tag, 42 | input.Label, 43 | ), 44 | } 45 | } else { 46 | body = struct { 47 | CreateAddressInput 48 | Pin string `json:"pin,omitempty"` 49 | }{ 50 | CreateAddressInput: input, 51 | Pin: c.EncryptPin(pin), 52 | } 53 | } 54 | 55 | var address Address 56 | if err := c.Post(ctx, "/addresses", body, &address); err != nil { 57 | return nil, err 58 | } 59 | 60 | return &address, nil 61 | } 62 | 63 | func (c *Client) ReadAddress(ctx context.Context, addressID string) (*Address, error) { 64 | uri := fmt.Sprintf("/addresses/%s", addressID) 65 | 66 | var address Address 67 | if err := c.Get(ctx, uri, nil, &address); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &address, nil 72 | } 73 | 74 | func ReadAddress(ctx context.Context, accessToken, addressID string) (*Address, error) { 75 | return NewFromAccessToken(accessToken).ReadAddress(ctx, addressID) 76 | } 77 | 78 | func (c *Client) ReadAddresses(ctx context.Context, assetID string) ([]*Address, error) { 79 | uri := fmt.Sprintf("/assets/%s/addresses", assetID) 80 | 81 | var addresses []*Address 82 | if err := c.Get(ctx, uri, nil, &addresses); err != nil { 83 | return nil, err 84 | } 85 | 86 | return addresses, nil 87 | } 88 | 89 | func ReadAddresses(ctx context.Context, accessToken, assetID string) ([]*Address, error) { 90 | return NewFromAccessToken(accessToken).ReadAddresses(ctx, assetID) 91 | } 92 | 93 | func (c *Client) DeleteAddress(ctx context.Context, addressID, pin string) error { 94 | body := map[string]interface{}{} 95 | if key, err := mixinnet.KeyFromString(pin); err == nil { 96 | body["pin_base64"] = c.EncryptTipPin(key, TIPAddressRemove, addressID) 97 | } else { 98 | body["pin"] = c.EncryptPin(pin) 99 | } 100 | 101 | uri := fmt.Sprintf("/addresses/%s/delete", addressID) 102 | return c.Post(ctx, uri, body, nil) 103 | } 104 | -------------------------------------------------------------------------------- /legacy_address_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestAddress(t *testing.T) { 12 | ctx := context.Background() 13 | store := decodeKeystoreAndPinFromEnv(t) 14 | 15 | c, err := NewFromKeystore(&store.Keystore) 16 | require.Nil(t, err, "init client") 17 | 18 | input := CreateAddressInput{ 19 | AssetID: "c6d0c728-2624-429b-8e0d-d9d19b6592fa", 20 | Destination: "1M7aEv3BhcB2AtBTVZXVKwfW3p2We1bavT", 21 | Tag: "", 22 | Label: "my btc address", 23 | } 24 | 25 | address, err := c.CreateAddress(ctx, input, store.Pin) 26 | require.Nil(t, err, "create address") 27 | 28 | t.Run("read address", func(t *testing.T) { 29 | _, err := c.ReadAddress(ctx, address.AddressID) 30 | assert.Nil(t, err, "read address") 31 | }) 32 | 33 | t.Run("read addresses", func(t *testing.T) { 34 | addresses, err := c.ReadAddresses(ctx, input.AssetID) 35 | require.Nil(t, err, "read addresses") 36 | 37 | var ids []string 38 | for _, address := range addresses { 39 | ids = append(ids, address.AddressID) 40 | } 41 | 42 | assert.Contains(t, ids, address.AddressID, "should contain") 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /legacy_assets.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | type Asset struct { 11 | AssetID string `json:"asset_id"` 12 | ChainID string `json:"chain_id"` 13 | AssetKey string `json:"asset_key,omitempty"` 14 | MixinID string `json:"mixin_id,omitempty"` 15 | Symbol string `json:"symbol,omitempty"` 16 | Name string `json:"name,omitempty"` 17 | IconURL string `json:"icon_url,omitempty"` 18 | PriceBTC decimal.Decimal `json:"price_btc,omitempty"` 19 | ChangeBTC decimal.Decimal `json:"change_btc,omitempty"` 20 | PriceUSD decimal.Decimal `json:"price_usd,omitempty"` 21 | ChangeUsd decimal.Decimal `json:"change_usd,omitempty"` 22 | Balance decimal.Decimal `json:"balance,omitempty"` 23 | Destination string `json:"destination,omitempty"` 24 | Tag string `json:"tag,omitempty"` 25 | Confirmations int `json:"confirmations,omitempty"` 26 | Capitalization float64 `json:"capitalization,omitempty"` 27 | DepositEntries []DepositEntry `json:"deposit_entries"` 28 | } 29 | 30 | type DepositEntry struct { 31 | Destination string `json:"destination"` 32 | Tag string `json:"tag"` 33 | Properties []string `json:"properties"` 34 | } 35 | 36 | func (c *Client) ReadAsset(ctx context.Context, assetID string) (*Asset, error) { 37 | uri := fmt.Sprintf("/assets/%s", assetID) 38 | 39 | var asset Asset 40 | if err := c.Get(ctx, uri, nil, &asset); err != nil { 41 | return nil, err 42 | } 43 | 44 | return &asset, nil 45 | } 46 | 47 | func ReadAsset(ctx context.Context, accessToken, assetID string) (*Asset, error) { 48 | return NewFromAccessToken(accessToken).ReadAsset(ctx, assetID) 49 | } 50 | 51 | func (c *Client) ReadAssets(ctx context.Context) ([]*Asset, error) { 52 | var assets []*Asset 53 | if err := c.Get(ctx, "/assets", nil, &assets); err != nil { 54 | return nil, err 55 | } 56 | 57 | return assets, nil 58 | } 59 | 60 | func ReadAssets(ctx context.Context, accessToken string) ([]*Asset, error) { 61 | return NewFromAccessToken(accessToken).ReadAssets(ctx) 62 | } 63 | 64 | func (c *Client) ReadAssetFee(ctx context.Context, assetID string) (decimal.Decimal, error) { 65 | uri := fmt.Sprintf("/assets/%s/fee", assetID) 66 | 67 | var body struct { 68 | Amount decimal.Decimal `json:"amount,omitempty"` 69 | } 70 | if err := c.Get(ctx, uri, nil, &body); err != nil { 71 | return decimal.Zero, nil 72 | } 73 | 74 | return body.Amount, nil 75 | } 76 | -------------------------------------------------------------------------------- /legacy_collectible_collection.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type CollectibleCollection struct { 9 | CollectionID string `json:"collection_id,omitempty"` 10 | Name string `json:"name,omitempty"` 11 | Type string `json:"type,omitempty"` 12 | IconUrl string `json:"icon_url,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | CreatedAt time.Time `json:"created_at,omitempty"` 15 | } 16 | 17 | // ReadCollectibleCollection request collectible collection 18 | func (c *Client) ReadCollectibleCollection(ctx context.Context, collectionID string) (*CollectibleCollection, error) { 19 | var collection CollectibleCollection 20 | if err := c.Get(ctx, "/collectibles/collections/"+collectionID, nil, &collection); err != nil { 21 | return nil, err 22 | } 23 | 24 | return &collection, nil 25 | } 26 | 27 | // ReadCollectibleCollection request collectible collection with accessToken 28 | func ReadCollectibleCollection(ctx context.Context, accessToken, collectionID string) (*CollectibleCollection, error) { 29 | return NewFromAccessToken(accessToken).ReadCollectibleCollection(ctx, collectionID) 30 | } 31 | -------------------------------------------------------------------------------- /legacy_collectible_mint.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/fox-one/mixin-sdk-go/v2/nft" 7 | "golang.org/x/crypto/sha3" 8 | ) 9 | 10 | func MetaHash(content []byte) []byte { 11 | b := sha3.Sum256(content) 12 | return b[:] 13 | } 14 | 15 | func BuildMintCollectibleMemo(collectionID string, token int64, metaHash []byte) string { 16 | b := nft.BuildMintNFO(collectionID, token, metaHash) 17 | return base64.RawURLEncoding.EncodeToString(b) 18 | } 19 | 20 | func GenerateCollectibleTokenID(collectionID string, token int64) string { 21 | b := nft.BuildTokenID(collectionID, token) 22 | return uuidHash(b) 23 | } 24 | 25 | func NewMintCollectibleTransferInput(traceID, collectionID string, token int64, metaHash []byte) TransferInput { 26 | input := TransferInput{ 27 | AssetID: nft.MintAssetId, 28 | Amount: nft.MintMinimumCost, 29 | TraceID: traceID, 30 | Memo: BuildMintCollectibleMemo(collectionID, token, metaHash), 31 | } 32 | 33 | input.OpponentMultisig.Receivers = nft.GroupMembers 34 | input.OpponentMultisig.Threshold = nft.GroupThreshold 35 | return input 36 | } 37 | -------------------------------------------------------------------------------- /legacy_collectible_mint_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGenerateCollectibleTokenID(t *testing.T) { 13 | collection := "10b44d45-8871-4ce3-aa2e-ff09af519f71" 14 | token := int64(14) 15 | tokenID := GenerateCollectibleTokenID(collection, token) 16 | 17 | store := newKeystoreFromEnv(t) 18 | client, err := NewFromKeystore(&store.Keystore) 19 | require.NoError(t, err) 20 | 21 | ctx := context.Background() 22 | cToken, err := client.ReadCollectiblesToken(ctx, tokenID) 23 | require.NoError(t, err) 24 | assert.Equal(t, collection, cToken.CollectionID) 25 | assert.Equal(t, strconv.Itoa(int(token)), cToken.Token) 26 | } 27 | -------------------------------------------------------------------------------- /legacy_collectible_token.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | ) 9 | 10 | type CollectibleTokenMeta struct { 11 | Group string `json:"group,omitempty"` 12 | Name string `json:"name,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | IconURL string `json:"icon_url,omitempty"` 15 | MediaURL string `json:"media_url,omitempty"` 16 | Mime string `json:"mime,omitempty"` 17 | Hash mixinnet.Hash `json:"hash,omitempty"` 18 | } 19 | 20 | type CollectibleToken struct { 21 | Type string `json:"type,omitempty"` 22 | CreatedAt time.Time `json:"created_at,omitempty"` 23 | CollectionID string `json:"collection_id,omitempty"` 24 | TokenID string `json:"token_id,omitempty"` 25 | Group string `json:"group,omitempty"` 26 | Token string `json:"token,omitempty"` 27 | MixinID mixinnet.Hash `json:"mixin_id,omitempty"` 28 | NFO mixinnet.TransactionExtra `json:"nfo,omitempty"` 29 | Meta CollectibleTokenMeta `json:"meta,omitempty"` 30 | } 31 | 32 | // ReadCollectiblesToken return the detail of CollectibleToken 33 | func (c *Client) ReadCollectiblesToken(ctx context.Context, id string) (*CollectibleToken, error) { 34 | var token CollectibleToken 35 | if err := c.Get(ctx, "/collectibles/tokens/"+id, nil, &token); err != nil { 36 | return nil, err 37 | } 38 | 39 | return &token, nil 40 | } 41 | 42 | // ReadCollectiblesToken request with access token and returns the detail of CollectibleToken 43 | func ReadCollectiblesToken(ctx context.Context, accessToken, tokenID string) (*CollectibleToken, error) { 44 | return NewFromAccessToken(accessToken).ReadCollectiblesToken(ctx, tokenID) 45 | } 46 | -------------------------------------------------------------------------------- /legacy_ghost_key.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 7 | ) 8 | 9 | type ( 10 | // GhostKeys transaction ghost keys 11 | GhostKeys struct { 12 | Mask mixinnet.Key `json:"mask"` 13 | Keys []mixinnet.Key `json:"keys"` 14 | } 15 | 16 | GhostInput struct { 17 | Receivers []string `json:"receivers"` 18 | Index uint8 `json:"index"` 19 | Hint string `json:"hint"` 20 | } 21 | ) 22 | 23 | func (c *Client) ReadGhostKeys(ctx context.Context, receivers []string, index uint8) (*GhostKeys, error) { 24 | input := &GhostInput{ 25 | Receivers: receivers, 26 | Index: index, 27 | Hint: newUUID(), 28 | } 29 | 30 | var resp GhostKeys 31 | if err := c.Post(ctx, "/outputs", input, &resp); err != nil { 32 | return nil, err 33 | } 34 | 35 | return &resp, nil 36 | } 37 | 38 | func (c *Client) BatchReadGhostKeys(ctx context.Context, inputs []*GhostInput) ([]*GhostKeys, error) { 39 | var resp []*GhostKeys 40 | if err := c.Post(ctx, "/outputs", inputs, &resp); err != nil { 41 | return nil, err 42 | } 43 | 44 | return resp, nil 45 | } 46 | -------------------------------------------------------------------------------- /legacy_multisig_assets.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import "context" 4 | 5 | func ReadMultisigAssets(ctx context.Context) ([]*Asset, error) { 6 | resp, err := Request(ctx).Get("/network/assets/multisig") 7 | if err != nil { 8 | return nil, err 9 | } 10 | 11 | var assets []*Asset 12 | if err := UnmarshalResponse(resp, &assets); err != nil { 13 | return nil, err 14 | } 15 | 16 | return assets, nil 17 | } 18 | -------------------------------------------------------------------------------- /legacy_payment.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | PaymentStatusPending = "pending" 9 | PaymentStatusPaid = "paid" 10 | ) 11 | 12 | type Payment struct { 13 | Recipient *User `json:"recipient,omitempty"` 14 | Asset *Asset `json:"asset,omitempty"` 15 | AssetID string `json:"asset_id,omitempty"` 16 | Amount string `json:"amount,omitempty"` 17 | TraceID string `json:"trace_id,omitempty"` 18 | Status string `json:"status,omitempty"` 19 | Memo string `json:"memo,omitempty"` 20 | Receivers []string `json:"receivers,omitempty"` 21 | Threshold uint8 `json:"threshold,omitempty"` 22 | CodeID string `json:"code_id,omitempty"` 23 | } 24 | 25 | func (c *Client) VerifyPayment(ctx context.Context, input TransferInput) (*Payment, error) { 26 | var resp Payment 27 | if err := c.Post(ctx, "/payments", input, &resp); err != nil { 28 | return nil, err 29 | } 30 | 31 | return &resp, nil 32 | } 33 | -------------------------------------------------------------------------------- /legacy_payment_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/shopspring/decimal" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestClient_VerifyPayment(t *testing.T) { 12 | ctx := context.Background() 13 | store := newKeystoreFromEnv(t) 14 | 15 | c, err := NewFromKeystore(&store.Keystore) 16 | require.Nil(t, err, "init client") 17 | 18 | t.Run("test normal payment", func(t *testing.T) { 19 | payment, err := c.VerifyPayment(ctx, TransferInput{ 20 | AssetID: "965e5c6e-434c-3fa9-b780-c50f43cd955c", 21 | OpponentID: "d33ec557-b14c-403f-9f7a-08ed0f5866d4", 22 | Amount: decimal.NewFromInt(1), 23 | TraceID: newUUID(), 24 | Memo: "memo", 25 | }) 26 | 27 | require.NoError(t, err) 28 | require.NotNil(t, payment.Recipient) 29 | require.NotNil(t, payment.Asset) 30 | require.Equal(t, PaymentStatusPending, payment.Status) 31 | }) 32 | 33 | t.Run("test multisig payment", func(t *testing.T) { 34 | input := TransferInput{ 35 | AssetID: "965e5c6e-434c-3fa9-b780-c50f43cd955c", 36 | Amount: decimal.NewFromInt(1), 37 | TraceID: newUUID(), 38 | Memo: "memo", 39 | } 40 | 41 | input.OpponentMultisig.Threshold = 1 42 | input.OpponentMultisig.Receivers = []string{ 43 | store.ClientID, 44 | "d33ec557-b14c-403f-9f7a-08ed0f5866d4", 45 | } 46 | payment, err := c.VerifyPayment(ctx, input) 47 | 48 | require.NoError(t, err) 49 | require.Nil(t, payment.Recipient) 50 | require.Nil(t, payment.Asset) 51 | require.NotEmpty(t, payment.CodeID) 52 | require.Equal(t, PaymentStatusPending, payment.Status) 53 | 54 | t.Log(URL.Codes(payment.CodeID)) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /legacy_transaction_external.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | type ExternalTransaction struct { 11 | TransactionID string `json:"transaction_id"` 12 | CreatedAt time.Time `json:"created_at"` 13 | TransactionHash string `json:"transaction_hash"` 14 | Sender string `json:"sender"` 15 | ChainId string `json:"chain_id"` 16 | AssetId string `json:"asset_id"` 17 | Amount decimal.Decimal `json:"amount"` 18 | Destination string `json:"destination"` 19 | Tag string `json:"tag"` 20 | Confirmations int64 `json:"confirmations"` 21 | Threshold int64 `json:"threshold"` 22 | } 23 | 24 | func ReadExternalTransactions(ctx context.Context, assetID, destination, tag string) ([]*ExternalTransaction, error) { 25 | params := make(map[string]string) 26 | if destination != "" { 27 | params["destination"] = destination 28 | } 29 | if tag != "" { 30 | params["tag"] = tag 31 | } 32 | if assetID != "" { 33 | params["asset"] = assetID 34 | } 35 | 36 | resp, err := Request(ctx).SetQueryParams(params).Get("/external/transactions") 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | var transactions []*ExternalTransaction 42 | if err := UnmarshalResponse(resp, &transactions); err != nil { 43 | return nil, err 44 | } 45 | 46 | return transactions, nil 47 | } 48 | -------------------------------------------------------------------------------- /legacy_transaction_external_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReadExternalTransactions(t *testing.T) { 11 | ctx := context.Background() 12 | transactions, err := ReadExternalTransactions(ctx, "", "", "") 13 | if err != nil { 14 | t.Error(err) 15 | t.FailNow() 16 | } 17 | 18 | for _, transaction := range transactions { 19 | assert.NotEmpty(t, transaction.TransactionID) 20 | assert.True(t, transaction.Amount.IsPositive()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /legacy_transaction_raw.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 10 | ) 11 | 12 | type ( 13 | // RawTransaction raw transaction 14 | RawTransaction struct { 15 | Type string `json:"type"` 16 | SnapshotID string `json:"snapshot_id,omitempty"` 17 | OpponentKey string `json:"opponent_key,omitempty"` 18 | AssetID string `json:"asset_id"` 19 | Amount string `json:"amount"` 20 | TraceID string `json:"trace_id"` 21 | Memo string `json:"memo"` 22 | State string `json:"state"` 23 | CreatedAt time.Time `json:"created_at"` 24 | TransactionHash string `json:"transaction_hash,omitempty"` 25 | SnapshotHash string `json:"snapshot_hash,omitempty"` 26 | SnapshotAt time.Time `json:"snapshot_at"` 27 | } 28 | ) 29 | 30 | func (c *Client) Transaction(ctx context.Context, in *TransferInput, pin string) (*RawTransaction, error) { 31 | paras := map[string]interface{}{ 32 | "asset_id": in.AssetID, 33 | "amount": in.Amount, 34 | "trace_id": in.TraceID, 35 | "memo": in.Memo, 36 | } 37 | 38 | if key, err := mixinnet.KeyFromString(pin); err == nil { 39 | paras["pin_base64"] = c.EncryptTipPin( 40 | key, 41 | TIPRawTransactionCreate, 42 | in.AssetID, 43 | in.OpponentKey, 44 | strings.Join(in.OpponentMultisig.Receivers, ""), 45 | fmt.Sprint(in.OpponentMultisig.Threshold), 46 | in.Amount.String(), 47 | in.TraceID, 48 | in.Memo, 49 | ) 50 | } else { 51 | paras["pin"] = c.EncryptPin(pin) 52 | } 53 | 54 | if in.OpponentKey != "" { 55 | paras["opponent_key"] = in.OpponentKey 56 | } else { 57 | paras["opponent_multisig"] = map[string]interface{}{ 58 | "receivers": in.OpponentMultisig.Receivers, 59 | "threshold": in.OpponentMultisig.Threshold, 60 | } 61 | } 62 | 63 | var resp RawTransaction 64 | if err := c.Post(ctx, "/transactions", paras, &resp); err != nil { 65 | return nil, err 66 | } 67 | 68 | return &resp, nil 69 | } 70 | -------------------------------------------------------------------------------- /legacy_transfer.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | // TransferInput input for transfer/verify payment request 12 | type TransferInput struct { 13 | AssetID string `json:"asset_id,omitempty"` 14 | OpponentID string `json:"opponent_id,omitempty"` 15 | Amount decimal.Decimal `json:"amount,omitempty"` 16 | TraceID string `json:"trace_id,omitempty"` 17 | Memo string `json:"memo,omitempty"` 18 | 19 | // OpponentKey used for raw transaction 20 | OpponentKey string `json:"opponent_key,omitempty"` 21 | 22 | OpponentMultisig struct { 23 | Receivers []string `json:"receivers,omitempty"` 24 | Threshold uint8 `json:"threshold,omitempty"` 25 | } `json:"opponent_multisig,omitempty"` 26 | } 27 | 28 | func (c *Client) Transfer(ctx context.Context, input *TransferInput, pin string) (*Snapshot, error) { 29 | var body interface{} 30 | if key, err := mixinnet.KeyFromString(pin); err == nil { 31 | body = struct { 32 | *TransferInput 33 | PinBase64 string `json:"pin_base64"` 34 | }{ 35 | TransferInput: input, 36 | PinBase64: c.EncryptTipPin( 37 | key, 38 | TIPTransferCreate, 39 | input.AssetID, 40 | input.OpponentID, 41 | input.Amount.String(), 42 | input.TraceID, 43 | input.Memo, 44 | ), 45 | } 46 | } else { 47 | body = struct { 48 | *TransferInput 49 | Pin string 50 | }{ 51 | TransferInput: input, 52 | Pin: c.EncryptPin(pin), 53 | } 54 | } 55 | 56 | var snapshot Snapshot 57 | if err := c.Post(ctx, "/transfers", body, &snapshot); err != nil { 58 | return nil, err 59 | } 60 | 61 | return &snapshot, nil 62 | } 63 | 64 | func (c *Client) ReadTransfer(ctx context.Context, traceID string) (*Snapshot, error) { 65 | uri := fmt.Sprintf("/transfers/trace/%s", traceID) 66 | 67 | var snapshot Snapshot 68 | if err := c.Get(ctx, uri, nil, &snapshot); err != nil { 69 | return nil, err 70 | } 71 | 72 | return &snapshot, nil 73 | } 74 | -------------------------------------------------------------------------------- /legacy_withdraw.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | type WithdrawInput struct { 11 | AddressID string `json:"address_id,omitempty"` 12 | Amount decimal.Decimal `json:"amount,omitempty"` 13 | TraceID string `json:"trace_id,omitempty"` 14 | Memo string `json:"memo,omitempty"` 15 | } 16 | 17 | func (c *Client) Withdraw(ctx context.Context, input WithdrawInput, pin string) (*Snapshot, error) { 18 | var body interface{} 19 | if key, err := mixinnet.KeyFromString(pin); err == nil { 20 | body = struct { 21 | WithdrawInput 22 | Pin string `json:"pin_base64"` 23 | }{ 24 | WithdrawInput: input, 25 | Pin: c.EncryptTipPin( 26 | key, 27 | TIPWithdrawalCreate, 28 | input.AddressID, 29 | input.Amount.String(), 30 | "0", // fee 31 | input.TraceID, 32 | input.Memo, 33 | ), 34 | } 35 | } else { 36 | body = struct { 37 | WithdrawInput 38 | Pin string 39 | }{ 40 | WithdrawInput: input, 41 | Pin: c.EncryptPin(pin), 42 | } 43 | } 44 | 45 | var snapshot Snapshot 46 | if err := c.Post(ctx, "/withdrawals", body, &snapshot); err != nil { 47 | return nil, err 48 | } 49 | 50 | return &snapshot, nil 51 | } 52 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fox-one/mixin-sdk-go/08020a556cd43116b3d0176284a4da62968cad40/logo/logo.png -------------------------------------------------------------------------------- /mixinnet/address.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "errors" 8 | "io" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/btcsuite/btcutil/base58" 13 | "github.com/shopspring/decimal" 14 | ) 15 | 16 | const MainNetworkID = "XIN" 17 | 18 | type ( 19 | Address struct { 20 | PrivateSpendKey Key `json:"private_spend_key"` 21 | PrivateViewKey Key `json:"private_view_key"` 22 | PublicSpendKey Key `json:"public_spend_key"` 23 | PublicViewKey Key `json:"public_view_key"` 24 | } 25 | ) 26 | 27 | func GenerateAddress(rand io.Reader, public ...bool) *Address { 28 | key := GenerateKey(rand) 29 | var a = Address{ 30 | PrivateSpendKey: key, 31 | PublicSpendKey: key.Public(), 32 | } 33 | 34 | if len(public) > 0 && public[0] { 35 | a.PrivateViewKey = a.PublicSpendKey.DeterministicHashDerive() 36 | } else { 37 | a.PrivateViewKey = GenerateKey(rand) 38 | } 39 | 40 | a.PublicViewKey = a.PrivateViewKey.Public() 41 | return &a 42 | } 43 | 44 | func AddressFromString(s string) (Address, error) { 45 | var a Address 46 | if !strings.HasPrefix(s, MainNetworkID) { 47 | return a, errors.New("invalid address network") 48 | } 49 | data := base58.Decode(s[len(MainNetworkID):]) 50 | if len(data) != 68 { 51 | return a, errors.New("invalid address format") 52 | } 53 | checksum := NewHash(append([]byte(MainNetworkID), data[:64]...)) 54 | if !bytes.Equal(checksum[:4], data[64:]) { 55 | return a, errors.New("invalid address checksum") 56 | } 57 | copy(a.PublicSpendKey[:], data[:32]) 58 | copy(a.PublicViewKey[:], data[32:]) 59 | return a, nil 60 | } 61 | 62 | func AddressFromPublicSpend(publicSpend Key) *Address { 63 | var a = Address{ 64 | PublicSpendKey: publicSpend, 65 | } 66 | a.PrivateViewKey = publicSpend.DeterministicHashDerive() 67 | a.PublicViewKey = a.PrivateViewKey.Public() 68 | 69 | return &a 70 | } 71 | 72 | func (a Address) String() string { 73 | data := append([]byte(MainNetworkID), a.PublicSpendKey[:]...) 74 | data = append(data, a.PublicViewKey[:]...) 75 | checksum := NewHash(data) 76 | data = append(a.PublicSpendKey[:], a.PublicViewKey[:]...) 77 | data = append(data, checksum[:4]...) 78 | return MainNetworkID + base58.Encode(data) 79 | } 80 | 81 | func (a Address) Hash() Hash { 82 | return NewHash(append(a.PublicSpendKey[:], a.PublicViewKey[:]...)) 83 | } 84 | 85 | func (a Address) MarshalJSON() ([]byte, error) { 86 | return []byte(strconv.Quote(a.String())), nil 87 | } 88 | 89 | func (a *Address) UnmarshalJSON(b []byte) error { 90 | unquoted, err := strconv.Unquote(string(b)) 91 | if err != nil { 92 | return err 93 | } 94 | m, err := AddressFromString(unquoted) 95 | if err != nil { 96 | return err 97 | } 98 | a.PrivateSpendKey = m.PrivateSpendKey 99 | a.PrivateViewKey = m.PrivateViewKey 100 | a.PublicSpendKey = m.PublicSpendKey 101 | a.PublicViewKey = m.PublicViewKey 102 | return nil 103 | } 104 | 105 | func (a Address) CreateUTXO(txVer uint8, outputIndex uint8, amount decimal.Decimal) *Output { 106 | r := GenerateKey(rand.Reader) 107 | pubGhost := DeriveGhostPublicKey(txVer, &r, &a.PublicViewKey, &a.PublicSpendKey, outputIndex) 108 | return &Output{ 109 | Type: 0, 110 | Script: NewThresholdScript(1), 111 | Amount: IntegerFromDecimal(amount), 112 | Mask: r.Public(), 113 | Keys: []Key{*pubGhost}, 114 | } 115 | } 116 | 117 | // 检查 transaction 是否是由该主网地址签发。满足以下所有条件则返回 true: 118 | // 1. 所有 input 对应的 utxo 只有一个 keys, 即 不是多签地址 转出 119 | // 2. 该 input 的 mask & keys 可以使用该地址的 private view 和 public spend 碰撞通过 120 | func (c *Client) VerifyTransaction(ctx context.Context, addr *Address, txHash Hash) (bool, error) { 121 | if !addr.PrivateViewKey.HasValue() || !addr.PublicSpendKey.HasValue() { 122 | return false, errors.New("invalid address: must contains both private view key and public spend key") 123 | } 124 | 125 | tx, err := c.GetTransaction(ctx, txHash) 126 | if err != nil { 127 | return false, err 128 | } else if !tx.Asset.HasValue() { 129 | return false, errors.New("GetTransaction failed") 130 | } 131 | 132 | for _, input := range tx.Inputs { 133 | preTx, err := c.GetTransaction(ctx, *input.Hash) 134 | if err != nil { 135 | return false, err 136 | } else if !preTx.Asset.HasValue() { 137 | return false, errors.New("GetTransaction failed") 138 | } 139 | 140 | if int(input.Index) >= len(preTx.Outputs) { 141 | return false, errors.New("invalid output index") 142 | } 143 | 144 | output := preTx.Outputs[input.Index] 145 | if len(output.Keys) != 1 { 146 | return false, nil 147 | } 148 | k := ViewGhostOutputKey(tx.Version, &output.Keys[0], &addr.PrivateViewKey, &output.Mask, input.Index) 149 | if !bytes.Equal(k[:], addr.PublicSpendKey[:]) { 150 | return false, nil 151 | } 152 | } 153 | 154 | return true, nil 155 | } 156 | -------------------------------------------------------------------------------- /mixinnet/address_test.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "crypto/rand" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewPublicMixinnetAddress(t *testing.T) { 11 | r := rand.Reader 12 | 13 | { 14 | a := GenerateAddress(r, true) 15 | b := GenerateAddress(r, true) 16 | if a.PrivateViewKey.String() == b.PrivateViewKey.String() { 17 | t.Errorf("same PrivateViewKey generated %v, %v", a.PrivateViewKey, b.PrivateViewKey) 18 | } 19 | } 20 | 21 | { 22 | pubSpend, err := KeyFromString("d03ac2718891838840c55f681b6b049af5b9efbf0d7d2a06d6741bbc17f68262") 23 | require.Nil(t, err) 24 | 25 | addr := AddressFromPublicSpend(pubSpend) 26 | require.NotNil(t, addr) 27 | require.Equal(t, "XINUF4GcHPYHUimJxvzoamCJjek6aGBqJAAwZVPGbbpSMGrvjngaZuYjnTyf4or9M7j71z4QzZS44FqvdEiT1fYZrmvSJyfj", addr.String()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mixinnet/client.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type ( 12 | Config struct { 13 | Safe bool 14 | Hosts []string 15 | } 16 | 17 | Client struct { 18 | http.Client 19 | safe bool 20 | hosts []string 21 | } 22 | ) 23 | 24 | var ( 25 | DefaultLegacyConfig = Config{ 26 | Safe: false, 27 | Hosts: legacyHosts, 28 | } 29 | DefaultSafeConfig = Config{ 30 | Safe: true, 31 | Hosts: safeHosts, 32 | } 33 | ) 34 | 35 | func NewClient(cfg Config) *Client { 36 | if len(cfg.Hosts) == 0 { 37 | if cfg.Safe { 38 | cfg.Hosts = safeHosts 39 | } else { 40 | cfg.Hosts = legacyHosts 41 | } 42 | } 43 | return &Client{ 44 | hosts: cfg.Hosts, 45 | safe: cfg.Safe, 46 | Client: http.Client{ 47 | Timeout: 10 * time.Second, 48 | }, 49 | } 50 | } 51 | 52 | func (c *Client) CallMixinNetRPC(ctx context.Context, resp interface{}, method string, params ...interface{}) error { 53 | bts, err := json.Marshal(map[string]interface{}{ 54 | "method": method, 55 | "params": params, 56 | }) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | r, err := c.Post(c.HostFromContext(ctx), "application/json", bytes.NewReader(bts)) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return UnmarshalResponse(r, resp) 67 | } 68 | 69 | func DecodeResponse(resp *http.Response) ([]byte, error) { 70 | var body struct { 71 | Error string `json:"error,omitempty"` 72 | Data json.RawMessage `json:"data,omitempty"` 73 | } 74 | defer resp.Body.Close() 75 | if err := json.NewDecoder((resp.Body)).Decode(&body); err != nil { 76 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 77 | return nil, createError(resp.StatusCode, resp.StatusCode, resp.Status) 78 | } 79 | return nil, createError(resp.StatusCode, resp.StatusCode, err.Error()) 80 | } 81 | 82 | if body.Error != "" { 83 | return nil, parseError(body.Error) 84 | } 85 | 86 | return body.Data, nil 87 | } 88 | 89 | func UnmarshalResponse(resp *http.Response, v interface{}) error { 90 | data, err := DecodeResponse(resp) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | if v != nil { 96 | return json.Unmarshal(data, v) 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /mixinnet/context.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | ) 7 | 8 | type contextKey int 9 | 10 | const ( 11 | _ contextKey = iota 12 | hostKey 13 | ) 14 | 15 | func (c *Client) HostFromContext(ctx context.Context) string { 16 | if host, ok := ctx.Value(hostKey).(string); ok { 17 | return host 18 | } 19 | return c.RandomHost() 20 | } 21 | 22 | func (c *Client) RandomHost() string { 23 | return c.hosts[rand.Int()%len(c.hosts)] 24 | } 25 | 26 | func (c *Client) WithHost(ctx context.Context, host string) context.Context { 27 | return context.WithValue(ctx, hostKey, host) 28 | } 29 | -------------------------------------------------------------------------------- /mixinnet/error.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // mixin error codes https://developers.mixin.one/api/alpha-mixin-network/errors/ 10 | const ( 11 | InvalidOutputKey = 2000001 12 | InputLocked = 2000002 13 | InvalidSignature = 2000003 14 | ) 15 | 16 | type Error struct { 17 | Status int `json:"status"` 18 | Code int `json:"code"` 19 | Description string `json:"description"` 20 | Extra map[string]interface{} `json:"extra,omitempty"` 21 | RequestID string `json:"request_id,omitempty"` 22 | } 23 | 24 | func (e *Error) Error() string { 25 | s := fmt.Sprintf("[%d/%d] %s", e.Status, e.Code, e.Description) 26 | for k, v := range e.Extra { 27 | s += fmt.Sprintf(" %v=%v", k, v) 28 | } 29 | 30 | if e.RequestID != "" { 31 | s += fmt.Sprintf(" id=%s", e.RequestID) 32 | } 33 | 34 | return s 35 | } 36 | 37 | func IsErrorCodes(err error, codes ...int) bool { 38 | var e *Error 39 | if errors.As(err, &e) { 40 | for _, code := range codes { 41 | if e.Code == code { 42 | return true 43 | } 44 | } 45 | } 46 | 47 | return false 48 | } 49 | 50 | func createError(status, code int, description string) error { 51 | return &Error{ 52 | Status: status, 53 | Code: code, 54 | Description: description, 55 | } 56 | } 57 | 58 | func parseError(errMsg string) error { 59 | if strings.HasPrefix(errMsg, "invalid output key ") { 60 | return createError(202, InvalidOutputKey, errMsg) 61 | } 62 | 63 | if strings.HasPrefix(errMsg, "input locked for transaction ") { 64 | return createError(202, InputLocked, errMsg) 65 | } 66 | 67 | if strings.HasPrefix(errMsg, "invalid tx signature number ") || 68 | strings.HasPrefix(errMsg, "invalid signature keys ") { 69 | return createError(202, InvalidSignature, errMsg) 70 | } 71 | 72 | return createError(202, 202, errMsg) 73 | } 74 | -------------------------------------------------------------------------------- /mixinnet/hash.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/zeebo/blake3" 10 | "golang.org/x/crypto/sha3" 11 | ) 12 | 13 | type ( 14 | Hash [32]byte 15 | ) 16 | 17 | // Hash 18 | 19 | func NewHash(data []byte) Hash { 20 | return sha3.Sum256(data) 21 | } 22 | 23 | func NewBlake3Hash(data []byte) Hash { 24 | return Hash(blake3.Sum256(data)) 25 | } 26 | 27 | func HashFromString(src string) (Hash, error) { 28 | var hash Hash 29 | data, err := hex.DecodeString(src) 30 | if err != nil { 31 | return hash, err 32 | } 33 | if len(data) != len(hash) { 34 | return hash, fmt.Errorf("invalid hash length %d", len(data)) 35 | } 36 | copy(hash[:], data) 37 | return hash, nil 38 | } 39 | 40 | func (h Hash) HasValue() bool { 41 | zero := Hash{} 42 | return !bytes.Equal(h[:], zero[:]) 43 | } 44 | 45 | func (h Hash) String() string { 46 | return hex.EncodeToString(h[:]) 47 | } 48 | 49 | func (h Hash) MarshalJSON() ([]byte, error) { 50 | return []byte(strconv.Quote(h.String())), nil 51 | } 52 | 53 | func (h *Hash) UnmarshalJSON(b []byte) error { 54 | unquoted, err := strconv.Unquote(string(b)) 55 | if err != nil { 56 | return err 57 | } 58 | data, err := hex.DecodeString(unquoted) 59 | if err != nil { 60 | return err 61 | } 62 | if len(data) != len(h) { 63 | return fmt.Errorf("invalid hash length %d", len(data)) 64 | } 65 | copy(h[:], data) 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /mixinnet/key_test.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestKeyFromString(t *testing.T) { 14 | msg := []byte("sign message") 15 | 16 | for i := 0; i < 2000; i++ { 17 | priv := GenerateEd25519Key() 18 | pub := priv.Public().(ed25519.PublicKey) 19 | 20 | key, err := KeyFromString(hex.EncodeToString(priv)) 21 | require.NoError(t, err, "KeyFromString(%s)", hex.EncodeToString(priv)) 22 | pubKey := key.Public() 23 | require.True(t, pubKey.CheckKey(), "CheckKey") 24 | require.True(t, bytes.Equal(pub[:], pubKey[:]), "public Key of (%s) not matched: %s != %s", key, pubKey, hex.EncodeToString(pub)) 25 | 26 | { 27 | sigBytes, err := priv.Sign(rand.Reader, msg, &ed25519.Options{}) 28 | require.Nil(t, err) 29 | 30 | var sig Signature 31 | copy(sig[:], sigBytes) 32 | require.True(t, pubKey.Verify(msg, sig)) 33 | require.True(t, ed25519.Verify(pub, msg, sigBytes)) 34 | } 35 | 36 | { 37 | sig := key.Sign(msg) 38 | sigBytes := make([]byte, len(sig)) 39 | copy(sigBytes, sig[:]) 40 | require.True(t, pubKey.Verify(msg, sig)) 41 | require.True(t, ed25519.Verify(pub, msg, sigBytes)) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mixinnet/number.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/big" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/fox-one/msgpack" 11 | "github.com/shopspring/decimal" 12 | ) 13 | 14 | const Precision = 8 15 | 16 | var Zero Integer 17 | 18 | type ( 19 | Integer struct { 20 | i big.Int 21 | } 22 | ) 23 | 24 | func init() { 25 | msgpack.RegisterExt(0, (*Integer)(nil)) 26 | Zero = NewInteger(0) 27 | } 28 | 29 | func NewInteger(x uint64) (v Integer) { 30 | p := new(big.Int).SetUint64(x) 31 | d := big.NewInt(int64(math.Pow(10, Precision))) 32 | v.i.Mul(p, d) 33 | return 34 | } 35 | 36 | func IntegerFromDecimal(d decimal.Decimal) (v Integer) { 37 | if d.Sign() <= 0 { 38 | panic(d) 39 | } 40 | s := d.Mul(decimal.New(1, Precision)).StringFixed(0) 41 | v.i.SetString(s, 10) 42 | return 43 | } 44 | 45 | func IntegerFromString(x string) (v Integer) { 46 | d, err := decimal.NewFromString(x) 47 | if err != nil { 48 | panic(err) 49 | } 50 | if d.Sign() <= 0 { 51 | panic(x) 52 | } 53 | s := d.Mul(decimal.New(1, Precision)).StringFixed(0) 54 | v.i.SetString(s, 10) 55 | return 56 | } 57 | 58 | func (x Integer) Add(y Integer) (v Integer) { 59 | if x.Sign() < 0 || y.Sign() <= 0 { 60 | panic(fmt.Sprint(x, y)) 61 | } 62 | 63 | v.i.Add(&x.i, &y.i) 64 | if v.Cmp(x) < 0 || v.Cmp(y) < 0 { 65 | panic(fmt.Sprint(x, y)) 66 | } 67 | return 68 | } 69 | 70 | func (x Integer) Sub(y Integer) (v Integer) { 71 | if x.Sign() < 0 || y.Sign() <= 0 { 72 | panic(fmt.Sprint(x, y)) 73 | } 74 | if x.Cmp(y) < 0 { 75 | panic(fmt.Sprint(x, y)) 76 | } 77 | 78 | v.i.Sub(&x.i, &y.i) 79 | return 80 | } 81 | 82 | func (x Integer) Mul(y int) (v Integer) { 83 | if x.Sign() < 0 || y <= 0 { 84 | panic(fmt.Sprint(x, y)) 85 | } 86 | 87 | v.i.Mul(&x.i, big.NewInt(int64(y))) 88 | return 89 | } 90 | 91 | func (x Integer) Div(y int) (v Integer) { 92 | if x.Sign() < 0 || y <= 0 { 93 | panic(fmt.Sprint(x, y)) 94 | } 95 | 96 | v.i.Div(&x.i, big.NewInt(int64(y))) 97 | return 98 | } 99 | 100 | func (x Integer) Count(y Integer) uint64 { 101 | if x.Sign() <= 0 || y.Sign() <= 0 || x.Cmp(y) < 0 { 102 | panic(fmt.Sprint(x, y)) 103 | } 104 | c := new(big.Int).Div(&x.i, &y.i) 105 | if !c.IsUint64() { 106 | panic(fmt.Sprint(x, y)) 107 | } 108 | return c.Uint64() 109 | } 110 | 111 | func (x Integer) Cmp(y Integer) int { 112 | return x.i.Cmp(&y.i) 113 | } 114 | 115 | func (x Integer) Sign() int { 116 | return x.i.Sign() 117 | } 118 | 119 | func (x Integer) String() string { 120 | s := x.i.String() 121 | p := len(s) - Precision 122 | if p > 0 { 123 | return s[:p] + "." + s[p:] 124 | } 125 | return "0." + strings.Repeat("0", -p) + s 126 | } 127 | 128 | func (x Integer) MarshalMsgpack() ([]byte, error) { 129 | return x.i.Bytes(), nil 130 | } 131 | 132 | func (x *Integer) UnmarshalMsgpack(data []byte) error { 133 | x.i.SetBytes(data) 134 | return nil 135 | } 136 | 137 | func (x Integer) MarshalJSON() ([]byte, error) { 138 | s := x.String() 139 | return []byte(strconv.Quote(s)), nil 140 | } 141 | 142 | func (x *Integer) UnmarshalJSON(b []byte) error { 143 | unquoted, err := strconv.Unquote(string(b)) 144 | if err != nil { 145 | return err 146 | } 147 | i := IntegerFromString(unquoted) 148 | x.i.SetBytes(i.i.Bytes()) 149 | return nil 150 | } 151 | -------------------------------------------------------------------------------- /mixinnet/rpc.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | const ( 9 | TxMethodSend = "sendrawtransaction" 10 | TxMethodGet = "gettransaction" 11 | TxMethodGetUtxo = "getutxo" 12 | ) 13 | 14 | func (c *Client) ReadConsensusInfo(ctx context.Context) (*ConsensusInfo, error) { 15 | var resp ConsensusInfo 16 | err := c.CallMixinNetRPC(ctx, &resp, "getinfo") 17 | return &resp, err 18 | } 19 | 20 | func (c *Client) SendRawTransaction(ctx context.Context, raw string) (*Transaction, error) { 21 | var tx Transaction 22 | if err := c.CallMixinNetRPC(ctx, &tx, TxMethodSend, raw); err != nil { 23 | if IsErrorCodes(err, InvalidOutputKey) { 24 | if tx, err := TransactionFromRaw(raw); err == nil { 25 | h, _ := tx.TransactionHash() 26 | if tx, err := c.GetTransaction(ctx, h); err == nil && tx.Asset.HasValue() { 27 | return tx, nil 28 | } 29 | } 30 | } 31 | return nil, err 32 | } else if tx.Hash == nil { 33 | return nil, errors.New("nil transaction hash") 34 | } 35 | 36 | return c.GetTransaction(ctx, *tx.Hash) 37 | } 38 | 39 | func (c *Client) GetTransaction(ctx context.Context, hash Hash) (*Transaction, error) { 40 | var tx Transaction 41 | if err := c.CallMixinNetRPC(ctx, &tx, TxMethodGet, hash); err != nil { 42 | return nil, err 43 | } 44 | return &tx, nil 45 | } 46 | 47 | func (c *Client) GetUTXO(ctx context.Context, hash Hash, outputIndex uint8) (*UTXO, error) { 48 | var utxo UTXO 49 | if err := c.CallMixinNetRPC(ctx, &utxo, TxMethodGetUtxo, hash, outputIndex); err != nil { 50 | return nil, err 51 | } 52 | return &utxo, nil 53 | } 54 | -------------------------------------------------------------------------------- /mixinnet/rpc_hosts.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | var ( 4 | safeHosts = []string{ 5 | "https://kernel.mixin.dev", 6 | } 7 | 8 | legacyHosts = []string{ 9 | "http://node-42.f1ex.io:8239", 10 | "http://node-fes.f1ex.io:8239", 11 | "http://mixin-node-01.b.watch:8239", 12 | "http://mixin-node-02.b.watch:8239", 13 | "http://mixin-node-03.b.watch:8239", 14 | "http://mixin-node-04.b.watch:8239", 15 | "http://lehigh.hotot.org:8239", 16 | "http://lehigh-2.hotot.org:8239", 17 | "http://node-okashi.mixin.fan:8239", 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /mixinnet/rpc_info.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | type ( 10 | Mint struct { 11 | Pool decimal.Decimal `json:"pool"` 12 | Pledge decimal.Decimal `json:"pledge"` 13 | Batch uint64 `json:"batch"` 14 | } 15 | 16 | Queue struct { 17 | Finals uint64 `json:"finals"` 18 | Caches uint64 `json:"caches"` 19 | } 20 | 21 | ConsensusNode struct { 22 | Node Hash `json:"node"` 23 | Signer Address `json:"signer"` 24 | Payee Address `json:"payee"` 25 | State string `json:"state"` 26 | Timestamp int64 `json:"timestamp"` 27 | Transaction Hash `json:"transaction"` 28 | Aggregator uint64 `json:"aggregator"` 29 | Works [2]uint64 `json:"works"` 30 | } 31 | 32 | GraphReferences struct { 33 | External Hash `json:"external"` 34 | Self Hash `json:"self"` 35 | } 36 | 37 | GraphSnapshot struct { 38 | Node Hash `json:"node"` 39 | Hash Hash `json:"hash"` 40 | References GraphReferences `json:"references"` 41 | Round uint64 `json:"round"` 42 | Timestamp int64 `json:"timestamp"` 43 | Transaction Hash `json:"transaction"` 44 | Signature string `json:"signature"` // CosiSignature 45 | Version int `json:"version"` 46 | } 47 | 48 | GraphCache struct { 49 | Node Hash `json:"node"` 50 | References GraphReferences `json:"references"` 51 | Timestamp int64 `json:"timestamp"` 52 | Round uint64 `json:"round"` 53 | Snapshots []*GraphSnapshot `json:"snapshots"` 54 | } 55 | 56 | GraphFinal struct { 57 | Node Hash `json:"node"` 58 | Hash Hash `json:"hash"` 59 | Start int64 `json:"start"` 60 | End int64 `json:"end"` 61 | Round uint64 `json:"round"` 62 | } 63 | 64 | Graph struct { 65 | SPS float64 `json:"sps"` 66 | Topology uint64 `json:"topology"` 67 | Consensus []*ConsensusNode `json:"consensus"` 68 | Final map[string]*GraphFinal `json:"final"` 69 | Cache map[string]*GraphCache `json:"cache"` 70 | } 71 | 72 | ConsensusInfo struct { 73 | Network Hash `json:"network"` 74 | Node Hash `json:"node"` 75 | Version string `json:"version"` 76 | Uptime string `json:"uptime"` 77 | Epoch time.Time `json:"epoch"` 78 | Timestamp time.Time `json:"timestamp"` 79 | Mint Mint `json:"mint"` 80 | Queue Queue `json:"queue"` 81 | Graph Graph `json:"graph"` 82 | } 83 | ) 84 | -------------------------------------------------------------------------------- /mixinnet/rpc_test.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRPC(t *testing.T) { 11 | ctx := context.Background() 12 | 13 | t.Run("legacy-network", func(t *testing.T) { 14 | client := NewClient(DefaultLegacyConfig) 15 | 16 | info, err := client.ReadConsensusInfo(ctx) 17 | require.Nil(t, err, "ReadConsensusInfo") 18 | require.Greater(t, info.Graph.Topology, uint64(0)) 19 | 20 | hash, err := HashFromString("abadfce0377eae4e0057289ddcd067068171f034814c476a0d2c4a7807f222a7") 21 | require.Nil(t, err) 22 | 23 | tx, err := client.GetTransaction(ctx, hash) 24 | require.Nil(t, err) 25 | 26 | tx.Hash = nil 27 | hash1, err := tx.TransactionHash() 28 | require.Nil(t, err) 29 | require.Equal(t, hash[:], hash1[:], "hash not matched: %v != %v", hash, hash1) 30 | 31 | tx1, err := client.SendRawTransaction(ctx, "77770003a99c2e0e2b1da4d648755ef19bd95139acbbe6564cfb06dec7cd34931ca72cdc000100b5127275c76409d54e8e56b17308ee1e7686fbdd624ec31beb5f897adba6e80000000000000000000100a300060138e6ae9f0000000000000000000000000000000000000000000000000000000000000000000000000000000040d1da563f64423cdd7544d9dadb0ffc5af1c403c7f900c0d9c4e627c6d03ca8bfb27b4a84b6627c77a57074db780d16968e902614a99f02dbc5267ddfaaf38935ffffff017cd3a768b3c56c35ff0b1c3ba88ff389d21fc8ed491d2962288c02efa17a29b730a6f3f06cbd7b96f5f512f639f837941f4da22bedd7e3ce3933a8a520b3f40d00000101") 32 | require.Nil(t, err) 33 | hash2, err := tx1.TransactionHash() 34 | require.Nil(t, err) 35 | require.Equal(t, hash[:], hash2[:], "hash not matched: %v != %v", hash, hash2) 36 | 37 | utxo, err := client.GetUTXO(ctx, *tx.Inputs[0].Hash, tx.Inputs[0].Index) 38 | require.Nil(t, err) 39 | require.Equal(t, hash[:], utxo.Lock[:]) 40 | require.Equal(t, tx.Inputs[0].Hash[:], utxo.Hash[:]) 41 | require.Equal(t, tx.Inputs[0].Index, utxo.Index) 42 | }) 43 | 44 | t.Run("safe-network", func(t *testing.T) { 45 | client := NewClient(DefaultSafeConfig) 46 | 47 | info, err := client.ReadConsensusInfo(ctx) 48 | require.Nil(t, err, "ReadConsensusInfo") 49 | require.Greater(t, info.Graph.Topology, uint64(0)) 50 | 51 | hash, err := HashFromString("97734eaedd70ab91a23f84dbef398538d7829da51a43cfcb8df81d4f010d7688") 52 | require.Nil(t, err) 53 | 54 | tx, err := client.GetTransaction(ctx, hash) 55 | require.Nil(t, err) 56 | 57 | hash1, err := tx.TransactionHash() 58 | require.Nil(t, err) 59 | require.Equal(t, hash[:], hash1[:], "hash not matched: %v != %v", hash, hash1) 60 | 61 | tx1, err := client.SendRawTransaction(ctx, "77770005a99c2e0e2b1da4d648755ef19bd95139acbbe6564cfb06dec7cd34931ca72cdc00015365637a68a57c8f2ef391f760a0ac262af95c52edc305f9201fb55997aabcaa0000000000000000000100a300060138e6ae9f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040b77f9f5f35c70cdfb7800e9d3ffcc48d4b2f069806054b0f57d004633d53f3aafafd4e3b1d1c91330d22bc4a0b57594d88bab7f132ffe9417bad92c8ba3b7bcbffffff019ea442f3e17c2def22aa0c742270d07dc1f866613dfb57175263ddf975e6b33187acaa9918dbf1867c5bb3c5fda5a7c8319b0dd4dc08a46cb9161d304f689f0400000101") 62 | require.Nil(t, err) 63 | hash2, err := tx1.TransactionHash() 64 | require.Nil(t, err) 65 | require.Equal(t, hash[:], hash2[:], "hash not matched: %v != %v", hash, hash2) 66 | 67 | utxo, err := client.GetUTXO(ctx, *tx.Inputs[0].Hash, tx.Inputs[0].Index) 68 | require.Nil(t, err) 69 | require.Equal(t, hash[:], utxo.Lock[:]) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /mixinnet/rpc_utxo.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | ) 6 | 7 | type ( 8 | UTXO struct { 9 | Type uint8 `json:"type"` 10 | Amount decimal.Decimal `json:"amount"` 11 | Hash Hash `json:"hash"` 12 | Index uint8 `json:"index,omitempty"` 13 | Lock *Hash `json:"lock,omitempty"` 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /mixinnet/signature.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha512" 6 | "encoding/hex" 7 | "fmt" 8 | "strconv" 9 | 10 | "filippo.io/edwards25519" 11 | ) 12 | 13 | type ( 14 | Signature [64]byte 15 | ) 16 | 17 | func (privateKey *Key) Sign(message []byte) Signature { 18 | var digest1, messageDigest, hramDigest [64]byte 19 | 20 | // the hash costs almost nothing compared to elliptic curve ops 21 | h := sha512.New() 22 | h.Write(privateKey[:32]) 23 | h.Sum(digest1[:0]) 24 | h.Reset() 25 | h.Write(digest1[32:]) 26 | h.Write(message) 27 | h.Sum(messageDigest[:0]) 28 | 29 | z, err := edwards25519.NewScalar().SetUniformBytes(messageDigest[:]) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | R := edwards25519.NewIdentityPoint().ScalarBaseMult(z) 35 | 36 | pub := privateKey.Public() 37 | h.Reset() 38 | h.Write(R.Bytes()) 39 | h.Write(pub[:]) 40 | h.Write(message[:]) 41 | h.Sum(hramDigest[:0]) 42 | x, err := edwards25519.NewScalar().SetUniformBytes(hramDigest[:]) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | y, err := edwards25519.NewScalar().SetCanonicalBytes(privateKey[:]) 48 | if err != nil { 49 | panic(privateKey.String()) 50 | } 51 | s := edwards25519.NewScalar().MultiplyAdd(x, y, z) 52 | 53 | var signature Signature 54 | copy(signature[:], R.Bytes()) 55 | copy(signature[32:], s.Bytes()) 56 | 57 | return signature 58 | } 59 | 60 | func (privateKey *Key) SignHash(h Hash) Signature { 61 | return privateKey.Sign(h[:]) 62 | } 63 | 64 | func (publicKey *Key) VerifyWithChallenge(sig Signature, a *edwards25519.Scalar) bool { 65 | p, err := edwards25519.NewIdentityPoint().SetBytes(publicKey[:]) 66 | if err != nil { 67 | return false 68 | } 69 | A := edwards25519.NewIdentityPoint().Negate(p) 70 | 71 | b, err := edwards25519.NewScalar().SetCanonicalBytes(sig[32:]) 72 | if err != nil { 73 | return false 74 | } 75 | R := edwards25519.NewIdentityPoint().VarTimeDoubleScalarBaseMult(a, A, b) 76 | return bytes.Equal(sig[:32], R.Bytes()) 77 | } 78 | 79 | func (publicKey *Key) Verify(message []byte, sig Signature) bool { 80 | h := sha512.New() 81 | h.Write(sig[:32]) 82 | h.Write(publicKey[:]) 83 | h.Write(message[:]) 84 | var digest [64]byte 85 | h.Sum(digest[:0]) 86 | 87 | x, err := edwards25519.NewScalar().SetUniformBytes(digest[:]) 88 | if err != nil { 89 | panic(err) 90 | } 91 | return publicKey.VerifyWithChallenge(sig, x) 92 | } 93 | 94 | func (publicKey *Key) VerifyHash(message Hash, sig Signature) bool { 95 | return publicKey.Verify(message[:], sig) 96 | } 97 | 98 | func (s Signature) String() string { 99 | return hex.EncodeToString(s[:]) 100 | } 101 | 102 | func (s Signature) MarshalJSON() ([]byte, error) { 103 | return []byte(strconv.Quote(s.String())), nil 104 | } 105 | 106 | func (s *Signature) UnmarshalJSON(b []byte) error { 107 | unquoted, err := strconv.Unquote(string(b)) 108 | if err != nil { 109 | return err 110 | } 111 | data, err := hex.DecodeString(unquoted) 112 | if err != nil { 113 | return err 114 | } 115 | if len(data) != len(s) { 116 | return fmt.Errorf("invalid signature length %d", len(data)) 117 | } 118 | copy(s[:], data) 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /mixinnet/transaction_extra.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/hex" 6 | "strconv" 7 | ) 8 | 9 | type ( 10 | TransactionExtra []byte 11 | ) 12 | 13 | // Transaction Extra 14 | 15 | func (e TransactionExtra) String() string { 16 | return base64.StdEncoding.EncodeToString(e[:]) 17 | } 18 | 19 | func (e TransactionExtra) MarshalJSON() ([]byte, error) { 20 | return []byte(strconv.Quote(e.String())), nil 21 | } 22 | 23 | func (e *TransactionExtra) UnmarshalJSON(b []byte) error { 24 | unquoted, err := strconv.Unquote(string(b)) 25 | if err != nil { 26 | return err 27 | } 28 | data, err := hex.DecodeString(unquoted) 29 | if err != nil { 30 | if data, err = base64.StdEncoding.DecodeString(unquoted); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | *e = data 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /mixinnet/transaction_input.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | type ( 11 | InputUTXO struct { 12 | Input 13 | 14 | Asset Hash `json:"asset"` 15 | Amount decimal.Decimal `json:"amount,omitempty"` 16 | } 17 | 18 | TransactionInput struct { 19 | TxVersion uint8 20 | Memo string 21 | Inputs []*InputUTXO 22 | Outputs []*Output 23 | References []Hash 24 | Hint string 25 | } 26 | ) 27 | 28 | func (input *TransactionInput) Asset() Hash { 29 | if len(input.Inputs) == 0 { 30 | return Hash{} 31 | } 32 | return input.Inputs[0].Asset 33 | } 34 | 35 | func (input *TransactionInput) TotalInputAmount() decimal.Decimal { 36 | var total decimal.Decimal 37 | for _, input := range input.Inputs { 38 | total = total.Add(input.Amount) 39 | } 40 | return total 41 | } 42 | 43 | func (input *TransactionInput) Validate() error { 44 | if len(input.Inputs) == 0 { 45 | return errors.New("no input utxo") 46 | } 47 | 48 | var ( 49 | total = input.TotalInputAmount() 50 | asset = input.Asset() 51 | ) 52 | 53 | if len(input.Memo) > ExtraSizeGeneralLimit { 54 | return errors.New("invalid memo, extra too long") 55 | } 56 | 57 | if len(input.Inputs) > SliceCountLimit || len(input.Outputs) > SliceCountLimit || len(input.References) > SliceCountLimit { 58 | return fmt.Errorf("invalid tx inputs or outputs %d %d %d", len(input.Inputs), len(input.Outputs), len(input.References)) 59 | } 60 | 61 | for _, input := range input.Inputs { 62 | if asset != input.Asset { 63 | return errors.New("invalid input utxo, asset not matched") 64 | } 65 | } 66 | 67 | for _, output := range input.Outputs { 68 | if total = total.Sub(decimal.RequireFromString(output.Amount.String())); total.IsNegative() { 69 | return errors.New("invalid output: amount exceed") 70 | } 71 | } 72 | 73 | if !total.IsZero() { 74 | return errors.New("invalid output: amount not matched") 75 | } 76 | return nil 77 | } 78 | 79 | func (input *TransactionInput) Build() (*Transaction, error) { 80 | if err := input.Validate(); err != nil { 81 | return nil, err 82 | } 83 | 84 | var tx = Transaction{ 85 | Version: input.TxVersion, 86 | Asset: input.Asset(), 87 | Extra: []byte(input.Memo), 88 | References: input.References, 89 | Outputs: input.Outputs, 90 | } 91 | if len(tx.Extra) > tx.ExtraLimit() { 92 | return nil, errors.New("memo too long") 93 | } 94 | // add inputs 95 | for _, input := range input.Inputs { 96 | tx.Inputs = append(tx.Inputs, &input.Input) 97 | } 98 | 99 | return &tx, nil 100 | } 101 | -------------------------------------------------------------------------------- /mixinnet/transaction_script.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | const ( 10 | Operator0 = 0x00 11 | Operator64 = 0x40 12 | OperatorSum = 0xfe 13 | OperatorCmp = 0xff 14 | ) 15 | 16 | type ( 17 | Script []uint8 18 | ) 19 | 20 | // Script 21 | 22 | func NewThresholdScript(threshold uint8) Script { 23 | return Script{OperatorCmp, OperatorSum, threshold} 24 | } 25 | 26 | func (s Script) VerifyFormat() error { 27 | if len(s) != 3 { 28 | return fmt.Errorf("invalid script %d", len(s)) 29 | } 30 | if s[0] != OperatorCmp || s[1] != OperatorSum { 31 | return fmt.Errorf("invalid script %d %d", s[0], s[1]) 32 | } 33 | return nil 34 | } 35 | 36 | func (s Script) Validate(sum int) error { 37 | err := s.VerifyFormat() 38 | if err != nil { 39 | return err 40 | } 41 | if sum < int(s[2]) { 42 | return fmt.Errorf("invalid signature keys %d %d", sum, s[2]) 43 | } 44 | return nil 45 | } 46 | 47 | func (s Script) String() string { 48 | return hex.EncodeToString(s[:]) 49 | } 50 | 51 | func (s Script) MarshalJSON() ([]byte, error) { 52 | return []byte(strconv.Quote(s.String())), nil 53 | } 54 | 55 | func (s *Script) UnmarshalJSON(b []byte) error { 56 | unquoted, err := strconv.Unquote(string(b)) 57 | if err != nil { 58 | return err 59 | } 60 | data, err := hex.DecodeString(unquoted) 61 | if err != nil { 62 | return err 63 | } 64 | *s = data 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /mixinnet/transaction_v1.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "github.com/fox-one/msgpack" 5 | ) 6 | 7 | type ( 8 | TransactionV1 struct { 9 | Transaction 10 | Signatures [][]*Signature `json:"signatures,omitempty" msgpack:",omitempty"` 11 | } 12 | ) 13 | 14 | func (t *TransactionV1) Dump() (string, error) { 15 | return t.Transaction.Dump() 16 | } 17 | 18 | func transactionV1FromRaw(bts []byte) (*Transaction, error) { 19 | var tx TransactionV1 20 | if err := msgpack.Unmarshal(bts, &tx); err != nil { 21 | return nil, err 22 | } 23 | if len(tx.Signatures) > 0 { 24 | tx.Transaction.Signatures = make([]map[uint16]*Signature, len(tx.Signatures)) 25 | for i, sigs := range tx.Signatures { 26 | tx.Transaction.Signatures[i] = make(map[uint16]*Signature, len(sigs)) 27 | for k, sig := range sigs { 28 | tx.Transaction.Signatures[i][uint16(k)] = sig 29 | } 30 | } 31 | } 32 | return &tx.Transaction, nil 33 | } 34 | -------------------------------------------------------------------------------- /mixinnet/util.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | func HashMembers(ids []string) string { 8 | sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) 9 | var in string 10 | for _, id := range ids { 11 | in = in + id 12 | } 13 | return NewHash([]byte(in)).String() 14 | } 15 | -------------------------------------------------------------------------------- /mixinnet/validation.go: -------------------------------------------------------------------------------- 1 | package mixinnet 2 | 3 | const ( 4 | ExtraSizeGeneralLimit = 256 5 | ExtraSizeStorageStep = 1024 6 | ExtraSizeStorageCapacity = 1024 * 1024 * 4 7 | ExtraStoragePriceStep = "0.001" 8 | SliceCountLimit = 256 9 | ReferencesCountLimit = 2 10 | ) 11 | 12 | var ( 13 | XINAssetId Hash 14 | ) 15 | 16 | func init() { 17 | XINAssetId = NewHash([]byte("c94ac88f-4671-3976-b60a-09064f1811e8")) 18 | } 19 | 20 | func (tx *Transaction) ExtraLimit() int { 21 | if tx.Version < TxVersionReferences { 22 | return ExtraSizeGeneralLimit 23 | } 24 | if tx.Asset != XINAssetId { 25 | return ExtraSizeGeneralLimit 26 | } 27 | if len(tx.Outputs) < 1 { 28 | return ExtraSizeGeneralLimit 29 | } 30 | out := tx.Outputs[0] 31 | if len(out.Keys) != 1 { 32 | return ExtraSizeGeneralLimit 33 | } 34 | if out.Type != OutputTypeScript { 35 | return ExtraSizeGeneralLimit 36 | } 37 | if out.Script.String() != "fffe40" { 38 | return ExtraSizeGeneralLimit 39 | } 40 | step := IntegerFromString(ExtraStoragePriceStep) 41 | if out.Amount.Cmp(step) < 0 { 42 | return ExtraSizeGeneralLimit 43 | } 44 | cells := out.Amount.Count(step) 45 | limit := cells * ExtraSizeStorageStep 46 | if limit > ExtraSizeStorageCapacity { 47 | return ExtraSizeStorageCapacity 48 | } 49 | return int(limit) 50 | } 51 | -------------------------------------------------------------------------------- /network.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type NetworkChain struct { 12 | ChainID string `json:"chain_id"` 13 | IconURL string `json:"icon_url"` 14 | Name string `json:"name"` 15 | Type string `json:"type"` 16 | WithdrawFee decimal.Decimal `json:"withdrawal_fee"` 17 | WithdrawTimestamp time.Time `json:"withdrawal_timestamp"` 18 | WithdrawPendingCount int64 `json:"withdrawal_pending_count"` 19 | DepositBlockHeight int64 `json:"deposit_block_height"` 20 | ExternalBlockHeight int64 `json:"external_block_height"` 21 | ManagedBlockHeight int64 `json:"managed_block_height"` 22 | IsSynchronized bool `json:"is_synchronized"` 23 | } 24 | 25 | type NetworkAsset struct { 26 | Amount decimal.Decimal `json:"amount"` 27 | AssetID string `json:"asset_id"` 28 | IconURL string `json:"icon_url"` 29 | Symbol string `json:"symbol"` 30 | } 31 | 32 | // NetworkInfo mixin network info 33 | type NetworkInfo struct { 34 | Assets []*NetworkAsset `json:"assets"` 35 | Chains []*NetworkChain `json:"chains"` 36 | AssetsCount decimal.Decimal `json:"assets_count"` 37 | PeakThroughput decimal.Decimal `json:"peak_throughput"` 38 | SnapshotsCount decimal.Decimal `json:"snapshots_count"` 39 | Type string `json:"type"` 40 | } 41 | 42 | type Ticker struct { 43 | Type string `json:"type"` 44 | PriceUSD decimal.Decimal `json:"price_usd"` 45 | PriceBTC decimal.Decimal `json:"price_btc"` 46 | } 47 | 48 | // ReadNetworkInfo read mixin network 49 | func ReadNetworkInfo(ctx context.Context) (*NetworkInfo, error) { 50 | resp, err := Request(ctx).Get("/network") 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var info NetworkInfo 56 | if err := UnmarshalResponse(resp, &info); err != nil { 57 | return nil, err 58 | } 59 | return &info, nil 60 | } 61 | 62 | // ReadNetworkAsset read mixin network asset by asset id 63 | func ReadNetworkAsset(ctx context.Context, assetID string) (*Asset, error) { 64 | uri := fmt.Sprintf("/network/assets/%s", assetID) 65 | 66 | resp, err := Request(ctx).Get(uri) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | var asset Asset 72 | if err := UnmarshalResponse(resp, &asset); err != nil { 73 | return nil, err 74 | } 75 | 76 | return &asset, nil 77 | } 78 | 79 | // ReadTopNetworkAssets read top network assets 80 | func ReadTopNetworkAssets(ctx context.Context) ([]*Asset, error) { 81 | resp, err := Request(ctx).Get("/network/assets/top") 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | var assets []*Asset 87 | if err := UnmarshalResponse(resp, &assets); err != nil { 88 | return nil, err 89 | } 90 | 91 | return assets, nil 92 | } 93 | 94 | // ReadNetworkAssetsBySymbol read mixin network assets by symbol 95 | func ReadNetworkAssetsBySymbol(ctx context.Context, symbol string) ([]*Asset, error) { 96 | uri := fmt.Sprintf("/network/assets/search/%s", symbol) 97 | 98 | resp, err := Request(ctx).Get(uri) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | var assets []*Asset 104 | if err := UnmarshalResponse(resp, &assets); err != nil { 105 | return nil, err 106 | } 107 | 108 | return assets, nil 109 | } 110 | 111 | // ReadTicker read mixin ticker of asset with offset 112 | func ReadTicker(ctx context.Context, assetID string, offset time.Time) (*Ticker, error) { 113 | params := map[string]string{ 114 | "asset": assetID, 115 | } 116 | if !offset.IsZero() { 117 | params["offset"] = offset.Format(time.RFC3339Nano) 118 | } 119 | resp, err := Request(ctx).SetQueryParams(params).Get("/network/ticker") 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | var ticker Ticker 125 | if err := UnmarshalResponse(resp, &ticker); err != nil { 126 | return nil, err 127 | } 128 | return &ticker, nil 129 | } 130 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | func AuthorizeToken(ctx context.Context, clientID, clientSecret string, code string, verifier string) (string, string, error) { 10 | params := map[string]interface{}{ 11 | "client_id": clientID, 12 | "client_secret": clientSecret, 13 | "code": code, 14 | "code_verifier": verifier, 15 | } 16 | 17 | resp, err := Request(ctx).SetBody(params).Post("/oauth/token") 18 | if err != nil { 19 | return "", "", err 20 | } 21 | 22 | var body struct { 23 | AccessToken string `json:"access_token"` 24 | Scope string `json:"scope"` 25 | } 26 | 27 | err = UnmarshalResponse(resp, &body) 28 | return body.AccessToken, body.Scope, err 29 | } 30 | 31 | type accessTokenAuth string 32 | 33 | func (a accessTokenAuth) SignToken(signature, requestID string, exp time.Duration) string { 34 | return string(a) 35 | } 36 | 37 | func (a accessTokenAuth) EncryptPin(pin string) string { 38 | panic(errors.New("[access token auth] encrypt pin: forbidden")) 39 | } 40 | -------------------------------------------------------------------------------- /oauth_ed25519.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/go-resty/resty/v2" 11 | "github.com/golang-jwt/jwt/v5" 12 | ) 13 | 14 | type OauthKeystore struct { 15 | ClientID string `json:"client_id,omitempty"` 16 | AuthID string `json:"authorization_id,omitempty"` 17 | Scope string `json:"scope,omitempty"` 18 | PrivateKey string `json:"private_key,omitempty"` 19 | VerifyKey string `json:"ed25519,omitempty"` 20 | } 21 | 22 | func AuthorizeEd25519(ctx context.Context, clientID, clientSecret string, code string, verifier string, privateKey ed25519.PrivateKey) (*OauthKeystore, error) { 23 | public := privateKey.Public().(ed25519.PublicKey) 24 | params := map[string]any{ 25 | "client_id": clientID, 26 | "client_secret": clientSecret, 27 | "code": code, 28 | "code_verifier": verifier, 29 | "ed25519": ed25519Encoding.EncodeToString(public), 30 | } 31 | 32 | resp, err := Request(ctx).SetBody(params).Post("/oauth/token") 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var key OauthKeystore 38 | if err := UnmarshalResponse(resp, &key); err != nil { 39 | return nil, err 40 | } 41 | 42 | key.ClientID = clientID 43 | key.PrivateKey = ed25519Encoding.EncodeToString(privateKey) 44 | 45 | return &key, nil 46 | } 47 | 48 | type OauthKeystoreAuth struct { 49 | *OauthKeystore 50 | signMethod jwt.SigningMethod 51 | signKey any 52 | verifyKey any 53 | } 54 | 55 | func AuthFromOauthKeystore(store *OauthKeystore) (*OauthKeystoreAuth, error) { 56 | auth := &OauthKeystoreAuth{ 57 | OauthKeystore: store, 58 | signMethod: jwt.SigningMethodEdDSA, 59 | } 60 | 61 | sign, err := ed25519Encoding.DecodeString(store.PrivateKey) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | auth.signKey = (ed25519.PrivateKey)(sign) 67 | 68 | verify, err := ed25519Encoding.DecodeString(store.VerifyKey) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | auth.verifyKey = (ed25519.PublicKey)(verify) 74 | 75 | return auth, nil 76 | } 77 | 78 | func (o *OauthKeystoreAuth) SignTokenAt(signature, requestID string, at time.Time, exp time.Duration) string { 79 | jwtMap := jwt.MapClaims{ 80 | "iss": o.ClientID, 81 | "aid": o.AuthID, 82 | "scp": o.Scope, 83 | "iat": at.Unix(), 84 | "exp": at.Add(exp).Unix(), 85 | "sig": signature, 86 | "jti": requestID, 87 | } 88 | 89 | token, err := jwt.NewWithClaims(o.signMethod, jwtMap).SignedString(o.signKey) 90 | if err != nil { 91 | panic(err) 92 | } 93 | 94 | return token 95 | } 96 | 97 | func (o *OauthKeystoreAuth) SignToken(signature, requestID string, exp time.Duration) string { 98 | return o.SignTokenAt(signature, requestID, time.Now(), exp) 99 | } 100 | 101 | func (o *OauthKeystoreAuth) EncryptPin(pin string) string { 102 | panic(errors.New("[oauth auth] encrypt pin: forbidden")) 103 | } 104 | 105 | func (o *OauthKeystoreAuth) Verify(resp *resty.Response) error { 106 | verifyToken := resp.Header().Get(xIntegrityToken) 107 | if verifyToken == "" && IsErrorCodes(UnmarshalResponse(resp, nil), Unauthorized) { 108 | return nil 109 | } 110 | 111 | var claim struct { 112 | jwt.RegisteredClaims 113 | Sign string `json:"sig,omitempty"` 114 | } 115 | 116 | if _, err := jwt.ParseWithClaims(verifyToken, &claim, func(t *jwt.Token) (any, error) { 117 | return o.verifyKey, nil 118 | }); err != nil { 119 | return err 120 | } 121 | 122 | if expect, got := claim.ID, resp.Header().Get(xRequestID); expect != got { 123 | return fmt.Errorf("token.jti mismatch, expect %q but got %q", expect, got) 124 | } 125 | 126 | if expect, got := claim.Sign, SignResponse(resp); expect != got { 127 | return fmt.Errorf("token.sig mismatch, expect %q but got %q", expect, got) 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /ownership.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | ) 9 | 10 | func (c *Client) TransferOwnership(ctx context.Context, newOwner, pin string) error { 11 | key, err := mixinnet.KeyFromString(pin) 12 | if err != nil { 13 | return err 14 | } 15 | var body = struct { 16 | UserID string `json:"user_id"` 17 | PinBase64 string `json:"pin_base64"` 18 | }{ 19 | UserID: newOwner, 20 | PinBase64: c.EncryptTipPin( 21 | key, 22 | TIPAppOwnershipTransfer, 23 | newOwner, 24 | ), 25 | } 26 | 27 | uri := fmt.Sprintf("/apps/%s/transfer", c.ClientID) 28 | if err := c.Post(ctx, uri, body, nil); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pin.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "regexp" 9 | "time" 10 | 11 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 12 | ) 13 | 14 | func (c *Client) VerifyPin(ctx context.Context, pin string) error { 15 | body := map[string]interface{}{} 16 | if key, err := mixinnet.KeyFromString(pin); err == nil { 17 | timestamp := uint64(time.Now().UnixNano()) 18 | tipBody := []byte(fmt.Sprintf("%s%032d", TIPVerify, timestamp)) 19 | body["timestamp"] = timestamp 20 | body["pin_base64"] = c.EncryptPin(key.Sign(tipBody).String()) 21 | } else { 22 | body["pin"] = c.EncryptPin(pin) 23 | } 24 | 25 | return c.Post(ctx, "/pin/verify", body, nil) 26 | } 27 | 28 | func (c *Client) ModifyPin(ctx context.Context, pin, newPin string) error { 29 | body := map[string]interface{}{} 30 | 31 | if pin != "" { 32 | body["old_pin"] = c.EncryptPin(pin) 33 | } 34 | 35 | if len(newPin) > 6 { 36 | counter := make([]byte, 8) 37 | binary.BigEndian.PutUint64(counter, 1) 38 | newPin = newPin + hex.EncodeToString(counter) 39 | } 40 | 41 | body["pin"] = c.EncryptPin(newPin) 42 | 43 | return c.Post(ctx, "/pin/update", body, nil) 44 | } 45 | 46 | var ( 47 | pinRegex = regexp.MustCompile(`^\d{6}$`) 48 | ) 49 | 50 | // ValidatePinPattern validate the pin with pinRegex 51 | func ValidatePinPattern(pin string) error { 52 | if len(pin) > 6 { 53 | if pinBts, err := hex.DecodeString(pin); err == nil && (len(pinBts) == 32 || len(pinBts) == 64) { 54 | return nil 55 | } 56 | } 57 | if !pinRegex.MatchString(pin) { 58 | return fmt.Errorf("pin must match regex pattern %q", pinRegex.String()) 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /pin_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_ValidatePinPattern(t *testing.T) { 8 | type args struct { 9 | pin string 10 | } 11 | tests := []struct { 12 | name string 13 | args args 14 | wantErr bool 15 | }{ 16 | { 17 | name: "valid pin", 18 | args: args{ 19 | pin: "123456", 20 | }, 21 | wantErr: false, 22 | }, 23 | { 24 | name: "empty pin", 25 | args: args{ 26 | pin: "", 27 | }, 28 | wantErr: true, 29 | }, 30 | { 31 | name: "short pin", 32 | args: args{ 33 | pin: "123", 34 | }, 35 | wantErr: true, 36 | }, 37 | { 38 | name: "long pin", 39 | args: args{ 40 | pin: "12345678", 41 | }, 42 | wantErr: true, 43 | }, 44 | { 45 | name: "pin with non-numeric", 46 | args: args{ 47 | pin: "123 23", 48 | }, 49 | wantErr: true, 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | if err := ValidatePinPattern(tt.args.pin); (err != nil) != tt.wantErr { 55 | t.Errorf("ValidatePinPattern() error = %v, wantErr %v", err, tt.wantErr) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /relationships.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | RelationshipActionAdd = "ADD" 9 | RelationshipActionRemove = "Remove" 10 | RelationshipActionUpdate = "UPDATE" 11 | RelationshipActionBlock = "BLOCK" 12 | RelationshipActionUnblock = "UNBLOCK" 13 | ) 14 | 15 | type RelationshipRequest struct { 16 | UserID string `json:"user_id,omitempty"` 17 | FullName string `json:"full_name,omitempty"` 18 | Action string `json:"action,omitempty"` 19 | } 20 | 21 | func (c *Client) UpdateRelationship(ctx context.Context, req RelationshipRequest) (*User, error) { 22 | var resp User 23 | if err := c.Post(ctx, "/relationships", req, &resp); err != nil { 24 | return nil, err 25 | } 26 | 27 | return &resp, nil 28 | } 29 | 30 | func (c *Client) AddFriend(ctx context.Context, userID, remark string) (*User, error) { 31 | return c.UpdateRelationship(ctx, RelationshipRequest{ 32 | UserID: userID, 33 | FullName: remark, 34 | Action: RelationshipActionAdd, 35 | }) 36 | } 37 | 38 | func (c *Client) RemoveFriend(ctx context.Context, userID string) (*User, error) { 39 | return c.UpdateRelationship(ctx, RelationshipRequest{ 40 | UserID: userID, 41 | Action: RelationshipActionRemove, 42 | }) 43 | } 44 | 45 | func (c *Client) RemarkFriend(ctx context.Context, userID, remark string) (*User, error) { 46 | return c.UpdateRelationship(ctx, RelationshipRequest{ 47 | UserID: userID, 48 | FullName: remark, 49 | Action: RelationshipActionUpdate, 50 | }) 51 | } 52 | 53 | func (c *Client) BlockUser(ctx context.Context, userID string) (*User, error) { 54 | return c.UpdateRelationship(ctx, RelationshipRequest{ 55 | UserID: userID, 56 | Action: RelationshipActionBlock, 57 | }) 58 | } 59 | 60 | func (c *Client) UnblockUser(ctx context.Context, userID string) (*User, error) { 61 | return c.UpdateRelationship(ctx, RelationshipRequest{ 62 | UserID: userID, 63 | Action: RelationshipActionUnblock, 64 | }) 65 | } 66 | 67 | func (c *Client) ListBlockingUsers(ctx context.Context) ([]*User, error) { 68 | var users []*User 69 | if err := c.Get(ctx, "/blocking_users", nil, &users); err != nil { 70 | return nil, err 71 | } 72 | 73 | return users, nil 74 | } 75 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/go-resty/resty/v2" 12 | ) 13 | 14 | var ( 15 | xRequestID = http.CanonicalHeaderKey("x-request-id") 16 | xIntegrityToken = http.CanonicalHeaderKey("x-integrity-token") 17 | xForceAuthentication = http.CanonicalHeaderKey("x-force-authentication") 18 | 19 | ErrResponseVerifyFailed = errors.New("response verify failed") 20 | ) 21 | 22 | var httpClient = resty.New(). 23 | SetHeader("Content-Type", "application/json"). 24 | SetBaseURL(DefaultApiHost). 25 | SetTimeout(10 * time.Second). 26 | SetPreRequestHook(func(c *resty.Client, r *http.Request) error { 27 | ctx := r.Context() 28 | requestID := r.Header.Get(xRequestID) 29 | if requestID == "" { 30 | requestID = RequestIdFromContext(ctx) 31 | r.Header.Set(xRequestID, requestID) 32 | } 33 | 34 | if s, ok := ctx.Value(signerKey).(Signer); ok { 35 | token := s.SignToken(SignRequest(r), requestID, time.Minute) 36 | r.Header.Set("Authorization", "Bearer "+token) 37 | r.Header.Set(xForceAuthentication, "true") 38 | } 39 | 40 | return nil 41 | }). 42 | OnAfterResponse(func(c *resty.Client, r *resty.Response) error { 43 | if r.IsError() { 44 | return nil 45 | } 46 | 47 | if err := checkResponseRequestID(r); err != nil { 48 | return err 49 | } 50 | 51 | if v, ok := r.Request.Context().Value(verifierKey).(Verifier); ok { 52 | if err := v.Verify(r); err != nil { 53 | return ErrResponseVerifyFailed 54 | } 55 | } 56 | 57 | return nil 58 | }) 59 | 60 | func GetClient() *http.Client { 61 | return httpClient.GetClient() 62 | } 63 | 64 | func GetRestyClient() *resty.Client { 65 | return httpClient 66 | } 67 | 68 | func checkResponseRequestID(r *resty.Response) error { 69 | expect := r.Request.Header.Get(xRequestID) 70 | got := r.Header().Get(xRequestID) 71 | if expect != got { 72 | return fmt.Errorf("%s mismatch, expect %q but got %q", xRequestID, expect, got) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func Request(ctx context.Context) *resty.Request { 79 | return httpClient.R().SetContext(ctx) 80 | } 81 | 82 | func DecodeResponse(resp *resty.Response) ([]byte, error) { 83 | var body struct { 84 | Error *Error `json:"error,omitempty"` 85 | Data json.RawMessage `json:"data,omitempty"` 86 | } 87 | 88 | if err := json.Unmarshal(resp.Body(), &body); err != nil { 89 | if resp.IsError() { 90 | return nil, createError(resp.StatusCode(), resp.StatusCode(), resp.Status()) 91 | } 92 | 93 | return nil, createError(resp.StatusCode(), resp.StatusCode(), err.Error()) 94 | } 95 | 96 | if body.Error != nil && body.Error.Code > 0 { 97 | return nil, body.Error 98 | } 99 | return body.Data, nil 100 | } 101 | 102 | func UnmarshalResponse(resp *resty.Response, v interface{}) (err error) { 103 | if requestID := extractRequestID(resp); requestID != "" { 104 | defer bindRequestID(&err, requestID) 105 | } 106 | 107 | data, err := DecodeResponse(resp) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | if v != nil { 113 | return json.Unmarshal(data, v) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func extractRequestID(r *resty.Response) string { 120 | if r != nil { 121 | return r.Request.Header.Get(xRequestID) 122 | } 123 | 124 | return "" 125 | } 126 | 127 | func bindRequestID(errp *error, id string) { 128 | if err := *errp; err != nil { 129 | *errp = WrapErrWithRequestID(err, id) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRequestIDInError(t *testing.T) { 11 | ctx := context.Background() 12 | client := NewFromAccessToken("anonymous") 13 | _, err := client.UserMe(ctx) 14 | require.NotNil(t, err, "401 unauthorised") 15 | 16 | if e, ok := err.(*Error); ok { 17 | require.NotEmpty(t, e.RequestID, "request id should in error") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /safe_asset.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type SafeAsset struct { 12 | AssetID string `json:"asset_id"` 13 | ChainID string `json:"chain_id"` 14 | FeeAssetID string `json:"fee_asset_id"` 15 | KernelAssetID string `json:"kernel_asset_id,omitempty"` 16 | Symbol string `json:"symbol,omitempty"` 17 | Name string `json:"name,omitempty"` 18 | IconURL string `json:"icon_url,omitempty"` 19 | PriceBTC decimal.Decimal `json:"price_btc,omitempty"` 20 | PriceUSD decimal.Decimal `json:"price_usd,omitempty"` 21 | ChangeBTC decimal.Decimal `json:"change_btc,omitempty"` 22 | ChangeUsd decimal.Decimal `json:"change_usd,omitempty"` 23 | PriceUpdatedAt time.Time `json:"price_updated_at,omitempty"` 24 | AssetKey string `json:"asset_key,omitempty"` 25 | Precision int32 `json:"precision,omitempty"` 26 | Dust decimal.Decimal `json:"dust,omitempty"` 27 | Confirmations int `json:"confirmations,omitempty"` 28 | } 29 | 30 | func (c *Client) SafeReadAsset(ctx context.Context, assetID string) (*SafeAsset, error) { 31 | uri := fmt.Sprintf("/safe/assets/%s", assetID) 32 | 33 | var asset SafeAsset 34 | if err := c.Get(ctx, uri, nil, &asset); err != nil { 35 | return nil, err 36 | } 37 | 38 | return &asset, nil 39 | } 40 | 41 | func SafeReadAsset(ctx context.Context, accessToken, assetID string) (*SafeAsset, error) { 42 | return NewFromAccessToken(accessToken).SafeReadAsset(ctx, assetID) 43 | } 44 | 45 | func (c *Client) SafeReadAssets(ctx context.Context) ([]*SafeAsset, error) { 46 | var assets []*SafeAsset 47 | if err := c.Get(ctx, "/safe/assets", nil, &assets); err != nil { 48 | return nil, err 49 | } 50 | 51 | return assets, nil 52 | } 53 | 54 | func SafeReadAssets(ctx context.Context, accessToken string) ([]*SafeAsset, error) { 55 | return NewFromAccessToken(accessToken).SafeReadAssets(ctx) 56 | } 57 | 58 | func (c *Client) SafeFetchAssets(ctx context.Context, assetIds []string) ([]*SafeAsset, error) { 59 | var assets []*SafeAsset 60 | if err := c.Post(ctx, "/safe/assets/fetch", assetIds, &assets); err != nil { 61 | return nil, err 62 | } 63 | 64 | return assets, nil 65 | } 66 | 67 | func SafeFetchAssets(ctx context.Context, accessToken string, assetIds []string) ([]*SafeAsset, error) { 68 | return NewFromAccessToken(accessToken).SafeFetchAssets(ctx, assetIds) 69 | } 70 | -------------------------------------------------------------------------------- /safe_asset_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSafeAssets(t *testing.T) { 12 | ctx := context.Background() 13 | require := require.New(t) 14 | 15 | store := newKeystoreFromEnv(t) 16 | dapp, err := NewFromKeystore(&store.Keystore) 17 | require.NoError(err, "init bot client") 18 | 19 | assets, err := dapp.SafeReadAssets(ctx) 20 | require.NoError(err, "ReadSafeAssets") 21 | require.NotEmpty(assets, "/safe/assets return empty") 22 | 23 | asset, err := dapp.SafeReadAsset(ctx, "965e5c6e-434c-3fa9-b780-c50f43cd955c") 24 | require.NoError(err, "ReadSafeAsset") 25 | require.NotNil(asset, "/safe/asset/:id return nil") 26 | 27 | bts, _ := json.MarshalIndent(asset, "", " ") 28 | t.Log(string(bts)) 29 | } 30 | -------------------------------------------------------------------------------- /safe_deposit.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type ( 12 | SafeDepositEntry struct { 13 | EntryID string `json:"entry_id,omitempty"` 14 | Members []string `json:"members,omitempty"` 15 | Threshold int `json:"threshold,omitempty"` 16 | ChainID string `json:"chain_id,omitempty"` 17 | Destination string `json:"destination,omitempty"` 18 | Tag string `json:"tag,omitempty"` 19 | IsPrimary bool `json:"is_primary,omitempty"` 20 | Signature string `json:"signature,omitempty"` 21 | } 22 | 23 | SafeDeposit struct { 24 | DepositID string `json:"deposit_id,omitempty"` 25 | Destination string `json:"destination,omitempty"` 26 | Tag string `json:"tag,omitempty"` 27 | ChainID string `json:"chain_id,omitempty"` 28 | AssetID string `json:"asset_id,omitempty"` 29 | KernelAssetID string `json:"kernel_asset_id,omitempty"` 30 | AssetKey string `json:"chain_key,omitempty"` 31 | Amount decimal.Decimal `json:"amount,omitempty"` 32 | TransactionHash string `json:"transaction_hash,omitempty"` 33 | OutputIndex uint64 `json:"output_index,omitempty"` 34 | BlockHash string `json:"block_hash,omitempty"` 35 | Confirmations uint64 `json:"confirmations,omitempty"` 36 | Threshold uint64 `json:"threshold,omitempty"` 37 | CreatedAt time.Time `json:"created_at,omitempty"` 38 | UpdatedAt time.Time `json:"updated_at,omitempty"` 39 | } 40 | ) 41 | 42 | func (c *Client) SafeCreateDepositEntries(ctx context.Context, receivers []string, threshold int, chain string) ([]*SafeDepositEntry, error) { 43 | if len(receivers) == 0 { 44 | receivers = []string{c.ClientID} 45 | } 46 | if threshold < 1 { 47 | threshold = 1 48 | } else if threshold > len(receivers) { 49 | return nil, fmt.Errorf("invalid threshold %d, expect [1 %d)", threshold, len(receivers)) 50 | } 51 | paras := map[string]interface{}{ 52 | "members": receivers, 53 | "threshold": threshold, 54 | "chain_id": chain, 55 | } 56 | var entries []*SafeDepositEntry 57 | if err := c.Post(ctx, "/safe/deposit/entries", paras, &entries); err != nil { 58 | return nil, err 59 | } 60 | 61 | return entries, nil 62 | } 63 | 64 | func (c *Client) SafeListDeposits(ctx context.Context, entry *SafeDepositEntry, asset string, offset time.Time, limit int) ([]*SafeDeposit, error) { 65 | paras := map[string]string{ 66 | "chain_id": entry.ChainID, 67 | "limit": fmt.Sprint(limit), 68 | } 69 | 70 | if !offset.IsZero() { 71 | paras["offset"] = offset.Format(time.RFC3339Nano) 72 | } 73 | 74 | if entry.Destination != "" { 75 | paras["destination"] = entry.Destination 76 | 77 | if entry.Tag != "" { 78 | paras["tag"] = entry.Tag 79 | } 80 | } 81 | if asset != "" { 82 | paras["asset"] = asset 83 | } 84 | 85 | var deposits []*SafeDeposit 86 | if err := c.Get(ctx, "/safe/deposits", paras, &deposits); err != nil { 87 | return nil, err 88 | } 89 | 90 | return deposits, nil 91 | } 92 | -------------------------------------------------------------------------------- /safe_deposit_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSafeDeposits(t *testing.T) { 13 | ctx := context.Background() 14 | require := require.New(t) 15 | 16 | store := newKeystoreFromEnv(t) 17 | dapp, err := NewFromKeystore(&store.Keystore) 18 | require.NoError(err, "init bot client") 19 | 20 | entries, err := dapp.SafeCreateDepositEntries(ctx, []string{dapp.ClientID}, 0, "b91e18ff-a9ae-3dc7-8679-e935d9a4b34b") 21 | require.NoError(err, "SafeCreateDepositEntries") 22 | require.NotEmpty(entries) 23 | { 24 | bts, _ := json.MarshalIndent(entries, "", " ") 25 | t.Log(string(bts)) 26 | } 27 | 28 | _, err = dapp.SafeListDeposits(ctx, entries[0], "", time.Time{}, 10) 29 | require.NoError(err, "SafeListDeposits") 30 | } 31 | -------------------------------------------------------------------------------- /safe_ghost_keys.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | ) 9 | 10 | func (c *Client) SafeCreateGhostKeys(ctx context.Context, inputs []*GhostInput, senders ...string) ([]*GhostKeys, error) { 11 | var ( 12 | body interface{} = inputs 13 | resp []*GhostKeys 14 | ) 15 | 16 | if len(senders) > 0 { 17 | body = map[string]interface{}{ 18 | "keys": inputs, 19 | "senders": senders, 20 | } 21 | } 22 | 23 | if err := c.Post(ctx, "/safe/keys", body, &resp); err != nil { 24 | return nil, err 25 | } 26 | 27 | return resp, nil 28 | } 29 | 30 | func SafeCreateXinAddressGhostKeys(txVer uint8, addresses []*mixinnet.Address, outputIndex uint8) *GhostKeys { 31 | r := mixinnet.GenerateKey(rand.Reader) 32 | keys := &GhostKeys{ 33 | Mask: r.Public(), 34 | Keys: make([]mixinnet.Key, len(addresses)), 35 | } 36 | 37 | for i, a := range addresses { 38 | k := mixinnet.DeriveGhostPublicKey(txVer, &r, &a.PublicViewKey, &a.PublicSpendKey, outputIndex) 39 | keys.Keys[i] = *k 40 | } 41 | 42 | return keys 43 | } 44 | -------------------------------------------------------------------------------- /safe_ghost_keys_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/gofrs/uuid/v5" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSafeGhostKeys(t *testing.T) { 12 | ctx := context.Background() 13 | require := require.New(t) 14 | 15 | store := newKeystoreFromEnv(t) 16 | dapp, err := NewFromKeystore(&store.Keystore) 17 | require.NoError(err, "init bot client") 18 | 19 | ghostKeys, err := dapp.SafeCreateGhostKeys(ctx, []*GhostInput{ 20 | { 21 | Receivers: []string{dapp.ClientID}, 22 | Hint: uuid.Must(uuid.NewV4()).String(), 23 | }, 24 | }) 25 | require.NoError(err, "SafeCreateGhostKeys") 26 | require.NotEmpty(ghostKeys) 27 | require.Equal(1, len(ghostKeys[0].Keys)) 28 | } 29 | -------------------------------------------------------------------------------- /safe_inscription.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | ) 9 | 10 | type ( 11 | SafeCollection struct { 12 | AssetKey string `json:"asset_key,omitempty"` 13 | CollectionHash mixinnet.Hash `json:"collection_hash,omitempty"` 14 | Description string `json:"description,omitempty"` 15 | IconURL string `json:"icon_url,omitempty"` 16 | KernelAssetID mixinnet.Hash `json:"kernel_asset_id,omitempty"` 17 | MinimumPrice string `json:"minimum_price,omitempty"` 18 | Name string `json:"name,omitempty"` 19 | Supply string `json:"supply,omitempty"` 20 | Symbol string `json:"symbol,omitempty"` 21 | Type string `json:"type,omitempty"` 22 | Unit string `json:"unit,omitempty"` 23 | CreatedAt time.Time `json:"created_at,omitempty"` 24 | UpdatedAt time.Time `json:"updated_at,omitempty"` 25 | } 26 | 27 | SafeCollectible struct { 28 | CollectionHash mixinnet.Hash `json:"collection_hash,omitempty"` 29 | ContentType string `json:"content_type,omitempty"` 30 | ContentURL string `json:"content_url,omitempty"` 31 | InscriptionHash mixinnet.Hash `json:"inscription_hash,omitempty"` 32 | OccupiedBy string `json:"occupied_by,omitempty"` 33 | Owner string `json:"owner,omitempty"` 34 | Recipient string `json:"recipient,omitempty"` 35 | Sequence int64 `json:"sequence,omitempty"` 36 | Type string `json:"type,omitempty"` 37 | CreatedAt time.Time `json:"created_at,omitempty"` 38 | UpdatedAt time.Time `json:"updated_at,omitempty"` 39 | } 40 | ) 41 | 42 | func ReadSafeCollection(ctx context.Context, collectionHash string) (*SafeCollection, error) { 43 | resp, err := Request(ctx).Get("/safe/inscriptions/collections/" + collectionHash) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | var collection SafeCollection 49 | if err := UnmarshalResponse(resp, &collection); err != nil { 50 | return nil, err 51 | } 52 | 53 | return &collection, nil 54 | } 55 | 56 | func ReadSafeCollectible(ctx context.Context, inscriptionHash string) (*SafeCollectible, error) { 57 | resp, err := Request(ctx).Get("/safe/inscriptions/items/" + inscriptionHash) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var collectible SafeCollectible 63 | if err := UnmarshalResponse(resp, &collectible); err != nil { 64 | return nil, err 65 | } 66 | 67 | return &collectible, nil 68 | } 69 | -------------------------------------------------------------------------------- /safe_inscription_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestReadSafeCollection(t *testing.T) { 11 | ctx := context.Background() 12 | require := require.New(t) 13 | 14 | testCollectionHash := "b3979998b8b5e705d553288bffd96d4e1cc719f3ae0b01ecac8539e1df81c16f" 15 | 16 | collection, err := ReadSafeCollection(ctx, testCollectionHash) 17 | require.NoError(err, "ReadSafeCollection") 18 | require.NotNil(collection) 19 | t.Log(collection) 20 | } 21 | func TestReadSafeCollectible(t *testing.T) { 22 | ctx := context.Background() 23 | require := require.New(t) 24 | 25 | testInscriptionHash := "94d20f04829dcfb2c6d3cdb7ba94b3f6b402eb0537d6aa48f76e14d21e84c784" 26 | 27 | inscription, err := ReadSafeCollectible(ctx, testInscriptionHash) 28 | 29 | require.NoError(err, "ReadSafeInscription") 30 | require.NotNil(inscription) 31 | t.Log(inscription) 32 | } 33 | -------------------------------------------------------------------------------- /safe_migrate.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | "golang.org/x/crypto/sha3" 9 | ) 10 | 11 | func (c *Client) SafeMigrate(ctx context.Context, priv string, pin string) (*User, error) { 12 | privKey, err := mixinnet.KeyFromString(priv) 13 | if err != nil { 14 | return nil, err 15 | } 16 | pubKey := privKey.Public() 17 | pinKey, err := mixinnet.KeyFromString(pin) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | sig := privKey.SignHash(sha3.Sum256([]byte(c.ClientID))) 23 | paras := map[string]interface{}{ 24 | "public_key": pubKey.String(), 25 | "signature": base64.RawURLEncoding.EncodeToString(sig[:]), 26 | "pin_base64": c.EncryptTipPin(pinKey, TIPSequencerRegister, c.ClientID, pubKey.String()), 27 | } 28 | 29 | var user User 30 | if err := c.Post(ctx, "/safe/users", paras, &user); err != nil { 31 | return nil, err 32 | } 33 | return &user, nil 34 | } 35 | -------------------------------------------------------------------------------- /safe_migrate_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/json" 7 | "testing" 8 | 9 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestSafeMigrate(t *testing.T) { 14 | ctx := context.Background() 15 | require := require.New(t) 16 | 17 | store := newKeystoreFromEnv(t) 18 | dapp, err := NewFromKeystore(&store.Keystore) 19 | require.NoError(err, "init bot client") 20 | 21 | priv := GenerateEd25519Key() 22 | _, keystore, err := dapp.CreateUser(ctx, priv, "name-ed25519") 23 | require.NoError(err, "create a user with a Ed25519 key") 24 | 25 | subClient, err := NewFromKeystore(keystore) 26 | require.NoError(err, "Ed25519 user client") 27 | 28 | pin := mixinnet.GenerateKey(rand.Reader) 29 | err = subClient.ModifyPin(context.TODO(), "", pin.Public().String()) 30 | require.NoError(err, "the Ed25519 user modifies pin") 31 | require.NoError(subClient.VerifyPin(ctx, pin.String()), "the Ed25519 user verify pin") 32 | 33 | spendKey := mixinnet.GenerateKey(rand.Reader) 34 | user, err := subClient.SafeMigrate(ctx, spendKey.String(), pin.String()) 35 | require.NoError(err, "migrate failed") 36 | require.Equal(subClient.ClientID, user.UserID) 37 | require.True(user.HasSafe) 38 | 39 | bts, _ := json.Marshal(keystore) 40 | t.Log("new keystore", string(bts)) 41 | t.Log("pin", pin) 42 | t.Log("spend key", spendKey) 43 | } 44 | -------------------------------------------------------------------------------- /safe_multisigs.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | type ( 13 | SafeMultisigRequest struct { 14 | RequestID string `json:"request_id,omitempty"` 15 | TransactionHash string `json:"transaction_hash,omitempty"` 16 | AssetID string `json:"asset_id,omitempty"` 17 | KernelAssetID mixinnet.Hash `json:"kernel_asset_id,omitempty"` 18 | Amount decimal.Decimal `json:"amount,omitempty"` 19 | SendersHash string `json:"senders_hash,omitempty"` 20 | SendersThreshold uint8 `json:"senders_threshold,omitempty"` 21 | Senders []string `json:"senders,omitempty"` 22 | Signers []string `json:"signers,omitempty"` 23 | Extra string `json:"extra,omitempty"` 24 | RawTransaction string `json:"raw_transaction"` 25 | CreatedAt time.Time `json:"created_at,omitempty"` 26 | UpdatedAt time.Time `json:"updated_at,omitempty"` 27 | Views []mixinnet.Key `json:"views,omitempty"` 28 | RevokedBy string `json:"revoked_by"` 29 | } 30 | ) 31 | 32 | func (c *Client) SafeCreateMultisigRequests(ctx context.Context, inputs []*SafeTransactionRequestInput) ([]*SafeMultisigRequest, error) { 33 | var resp []*SafeMultisigRequest 34 | if err := c.Post(ctx, "/safe/multisigs", inputs, &resp); err != nil { 35 | return nil, err 36 | } 37 | 38 | return resp, nil 39 | } 40 | 41 | func (c *Client) SafeReadMultisigRequests(ctx context.Context, idOrHash string) (*SafeMultisigRequest, error) { 42 | var resp SafeMultisigRequest 43 | if err := c.Get(ctx, "/safe/multisigs/"+idOrHash, nil, &resp); err != nil { 44 | return nil, err 45 | } 46 | 47 | return &resp, nil 48 | } 49 | 50 | func (c *Client) SafeCreateMultisigRequest(ctx context.Context, input *SafeTransactionRequestInput) (*SafeMultisigRequest, error) { 51 | requests, err := c.SafeCreateMultisigRequests(ctx, []*SafeTransactionRequestInput{input}) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return requests[0], nil 57 | } 58 | 59 | func (c *Client) SafeSignMultisigRequest(ctx context.Context, input *SafeTransactionRequestInput) (*SafeMultisigRequest, error) { 60 | var resp SafeMultisigRequest 61 | uri := fmt.Sprintf("/safe/multisigs/%s/sign", input.RequestID) 62 | if err := c.Post(ctx, uri, input, &resp); err != nil { 63 | return nil, err 64 | } 65 | return &resp, nil 66 | } 67 | 68 | func (c *Client) SafeUnlockMultisigRequest(ctx context.Context, requestID string) (*SafeMultisigRequest, error) { 69 | var resp SafeMultisigRequest 70 | uri := fmt.Sprintf("/safe/multisigs/%s/unlock", requestID) 71 | if err := c.Post(ctx, uri, nil, &resp); err != nil { 72 | return nil, err 73 | } 74 | 75 | return &resp, nil 76 | } 77 | -------------------------------------------------------------------------------- /safe_multisigs_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/shopspring/decimal" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestSafeMultisigs(t *testing.T) { 14 | ctx := context.Background() 15 | require := require.New(t) 16 | 17 | store := newKeystoreFromEnv(t) 18 | dapp, err := NewFromKeystore(&store.Keystore) 19 | require.NoError(err, "init bot client") 20 | 21 | t.Run("safe-network", func(t *testing.T) { 22 | utxos, err := dapp.SafeListUtxos(ctx, SafeListUtxoOption{ 23 | Members: []string{dapp.ClientID}, 24 | Limit: 1, 25 | State: SafeUtxoStateUnspent, 26 | }) 27 | require.NoError(err, "SafeListUtxos") 28 | if len(utxos) == 0 { 29 | t.Log("empty unspent utxo") 30 | return 31 | } 32 | { 33 | b := NewSafeTransactionBuilder(utxos) 34 | b.Memo = "Transfer To Multisig" 35 | b.Hint = newUUID() 36 | 37 | tx, err := dapp.MakeTransaction(ctx, b, []*TransactionOutput{ 38 | { 39 | Address: RequireNewMixAddress([]string{dapp.ClientID, "6a00a4bc-229e-3c39-978a-91d2d6c382bf"}, 1), 40 | Amount: decimal.New(1, -8), 41 | }, 42 | }) 43 | require.NoError(err, "MakeTransaction") 44 | 45 | raw, err := tx.Dump() 46 | require.NoError(err, "Dump") 47 | t.Log(raw) 48 | 49 | request, err := dapp.SafeCreateTransactionRequest(ctx, &SafeTransactionRequestInput{ 50 | RequestID: uuidHash([]byte(utxos[0].OutputID + ":SafeCreateTransactionRequest")), 51 | RawTransaction: raw, 52 | }) 53 | require.NoError(err, "SafeCreateTransactionRequest") 54 | err = SafeSignTransaction( 55 | tx, 56 | store.SpendKey, 57 | request.Views, 58 | 0, 59 | ) 60 | require.NoError(err, "SafeSignTransaction") 61 | 62 | signedRaw, err := tx.Dump() 63 | require.NoError(err, "tx.Dump") 64 | 65 | request1, err := dapp.SafeSubmitTransactionRequest(ctx, &SafeTransactionRequestInput{ 66 | RequestID: request.RequestID, 67 | RawTransaction: signedRaw, 68 | }) 69 | require.NoError(err, "SafeSubmitTransactionRequest") 70 | 71 | _, err = dapp.SafeReadTransactionRequest(ctx, request1.RequestID) 72 | require.NoError(err, "SafeReadTransactionRequest") 73 | t.Log(request1.TransactionHash) 74 | 75 | utxos = []*SafeUtxo{ 76 | { 77 | OutputID: newUUID(), 78 | KernelAssetID: tx.Asset, 79 | TransactionHash: *tx.Hash, 80 | OutputIndex: 0, 81 | Amount: decimal.RequireFromString(tx.Outputs[0].Amount.String()), 82 | ReceiversThreshold: 1, 83 | Receivers: []string{dapp.ClientID, "6a00a4bc-229e-3c39-978a-91d2d6c382bf"}, 84 | }, 85 | } 86 | time.Sleep(time.Second * 10) 87 | } 88 | var k uint16 = 0 89 | if strings.Compare(dapp.ClientID, "6a00a4bc-229e-3c39-978a-91d2d6c382bf") > 0 { 90 | k = 1 91 | } 92 | 93 | { 94 | b := NewSafeTransactionBuilder(utxos) 95 | b.Memo = "Transfer From Multisig" 96 | 97 | tx, err := dapp.MakeTransaction(ctx, b, []*TransactionOutput{ 98 | { 99 | Address: RequireNewMixAddress([]string{dapp.ClientID}, 1), 100 | Amount: decimal.New(1, -8), 101 | }, 102 | }) 103 | require.NoError(err, "MakeTransaction") 104 | 105 | raw, err := tx.Dump() 106 | require.NoError(err, "Dump") 107 | 108 | request, err := dapp.SafeCreateMultisigRequest(ctx, &SafeTransactionRequestInput{ 109 | RequestID: uuidHash([]byte(utxos[0].OutputID + ":SafeCreateMultisigRequest")), 110 | RawTransaction: raw, 111 | }) 112 | require.NoError(err, "SafeCreateMultisigRequest") 113 | 114 | _, err = dapp.SafeUnlockMultisigRequest(ctx, request.RequestID) 115 | require.NoError(err, "SafeUnlockMultisigRequests") 116 | 117 | { 118 | hash, err := tx.TransactionHash() 119 | require.NoError(err, "TransactionHash") 120 | 121 | request1, err := dapp.SafeReadMultisigRequests(ctx, request.RequestID) 122 | require.NoError(err, "SafeReadMultisigRequests") 123 | require.Equal(hash.String(), request1.TransactionHash) 124 | } 125 | 126 | err = SafeSignTransaction( 127 | tx, 128 | store.SpendKey, 129 | request.Views, 130 | k, 131 | ) 132 | require.NoError(err, "SafeSignTransaction") 133 | 134 | signedRaw, err := tx.Dump() 135 | require.NoError(err, "tx.Dump") 136 | t.Log("signed tx", signedRaw) 137 | 138 | request, err = dapp.SafeSignMultisigRequest(ctx, &SafeTransactionRequestInput{ 139 | RequestID: request.RequestID, 140 | RawTransaction: signedRaw, 141 | }) 142 | require.NoError(err, "SafeSignMultisigRequests") 143 | 144 | _, err = dapp.SafeUnlockMultisigRequest(ctx, request.RequestID) 145 | require.Error(err, "SafeUnlockMultisigRequests Forbidden") 146 | } 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /safe_snapshot.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | type ( 13 | SafeSnapshotDeposit struct { 14 | DepositHash string `json:"deposit_hash,omitempty"` 15 | DepositIndex uint64 `json:"deposit_index,omitempty"` 16 | Sender string `json:"sender,omitempty"` 17 | } 18 | 19 | SafeSnapshot struct { 20 | SnapshotID string `json:"snapshot_id,omitempty"` 21 | RequestID string `json:"request_id,omitempty"` 22 | UserID string `json:"user_id,omitempty"` 23 | OpponentID string `json:"opponent_id,omitempty"` 24 | TransactionHash *mixinnet.Hash `json:"transaction_hash,omitempty"` 25 | AssetID string `json:"asset_id,omitempty"` 26 | KernelAssetID string `json:"kernel_asset_id,omitempty"` 27 | Amount decimal.Decimal `json:"amount,omitempty"` 28 | Memo string `json:"memo,omitempty"` 29 | CreatedAt time.Time `json:"created_at"` 30 | Deposit *SafeSnapshotDeposit `json:"deposit,omitempty"` 31 | InscriptionHash *mixinnet.Hash `json:"inscription_hash,omitempty"` 32 | } 33 | ) 34 | 35 | func (c *Client) ReadSafeSnapshot(ctx context.Context, snapshotID string) (*SafeSnapshot, error) { 36 | var snapshot SafeSnapshot 37 | if err := c.Get(ctx, "/safe/snapshots/"+snapshotID, nil, &snapshot); err != nil { 38 | return nil, err 39 | } 40 | 41 | return &snapshot, nil 42 | } 43 | 44 | func ReadSafeSnapshot(ctx context.Context, accessToken, snapshotID string) (*SafeSnapshot, error) { 45 | return NewFromAccessToken(accessToken).ReadSafeSnapshot(ctx, snapshotID) 46 | } 47 | 48 | func (c *Client) ReadSafeSnapshots(ctx context.Context, assetID string, offset time.Time, order string, limit int) ([]*SafeSnapshot, error) { 49 | params := buildReadSafeSnapshotsParams("", assetID, offset, order, limit) 50 | 51 | var snapshots []*SafeSnapshot 52 | if err := c.Get(ctx, "/safe/snapshots", params, &snapshots); err != nil { 53 | return nil, err 54 | } 55 | 56 | return snapshots, nil 57 | } 58 | 59 | func ReadSafeSnapshots(ctx context.Context, accessToken string, assetID string, offset time.Time, order string, limit int) ([]*SafeSnapshot, error) { 60 | return NewFromAccessToken(accessToken).ReadSafeSnapshots(ctx, assetID, offset, order, limit) 61 | } 62 | 63 | // list safe snapshots of dapp & sub wallets created by this dapp 64 | func (c *Client) ReadSafeAppSnapshots(ctx context.Context, assetID string, offset time.Time, order string, limit int) ([]*SafeSnapshot, error) { 65 | params := buildReadSafeSnapshotsParams(c.ClientID, assetID, offset, order, limit) 66 | 67 | var snapshots []*SafeSnapshot 68 | if err := c.Get(ctx, "/safe/snapshots", params, &snapshots); err != nil { 69 | return nil, err 70 | } 71 | 72 | return snapshots, nil 73 | } 74 | 75 | func buildReadSafeSnapshotsParams(appID string, assetID string, offset time.Time, order string, limit int) map[string]string { 76 | params := make(map[string]string) 77 | 78 | if appID != "" { 79 | params["app"] = appID 80 | } 81 | 82 | if assetID != "" { 83 | params["asset"] = assetID 84 | } 85 | 86 | if !offset.IsZero() { 87 | params["offset"] = offset.UTC().Format(time.RFC3339Nano) 88 | } 89 | 90 | switch order { 91 | case "ASC", "DESC": 92 | default: 93 | order = "DESC" 94 | } 95 | 96 | params["order"] = order 97 | 98 | if limit > 0 { 99 | params["limit"] = strconv.Itoa(limit) 100 | } 101 | 102 | return params 103 | } 104 | 105 | func (c *Client) SafeNotifySnapshot(ctx context.Context, receiverID string, hash mixinnet.Hash, index uint8) error { 106 | const uri = "/safe/snapshots/notifications" 107 | return c.Post(ctx, uri, map[string]interface{}{ 108 | "transaction_hash": hash.String(), 109 | "output_index": index, 110 | "receiver_id": receiverID, 111 | }, nil) 112 | } 113 | -------------------------------------------------------------------------------- /safe_snapshot_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSafeSnapshots(t *testing.T) { 13 | ctx := context.Background() 14 | require := require.New(t) 15 | 16 | store := newKeystoreFromEnv(t) 17 | dapp, err := NewFromKeystore(&store.Keystore) 18 | require.NoError(err, "init bot client") 19 | 20 | snapshots, err := dapp.ReadSafeSnapshots(ctx, "", time.Time{}, "ASC", 10) 21 | require.NoError(err, "ReadSafeSnapshots") 22 | 23 | bts, _ := json.MarshalIndent(snapshots, "", " ") 24 | t.Log(string(bts)) 25 | } 26 | -------------------------------------------------------------------------------- /safe_transaction_request.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type ( 12 | SafeTransactionRequestInput struct { 13 | RequestID string `json:"request_id"` 14 | RawTransaction string `json:"raw"` 15 | } 16 | 17 | SafeTransactionReceiver struct { 18 | Members []string `json:"members,omitempty"` 19 | MemberHash mixinnet.Hash `json:"members_hash,omitempty"` 20 | Threshold uint8 `json:"threshold,omitempty"` 21 | } 22 | 23 | SafeTransactionRequest struct { 24 | RequestID string `json:"request_id,omitempty"` 25 | TransactionHash string `json:"transaction_hash,omitempty"` 26 | UserID string `json:"user_id,omitempty"` 27 | KernelAssetID mixinnet.Hash `json:"kernel_asset_id,omitempty"` 28 | AssetID mixinnet.Hash `json:"asset_id,omitempty"` 29 | Amount decimal.Decimal `json:"amount,omitempty"` 30 | CreatedAt time.Time `json:"created_at,omitempty"` 31 | UpdatedAt time.Time `json:"updated_at,omitempty"` 32 | Extra string `json:"extra,omitempty"` 33 | Receivers []*SafeTransactionReceiver `json:"receivers,omitempty"` 34 | Senders []string `json:"senders,omitempty"` 35 | SendersHash string `json:"senders_hash,omitempty"` 36 | SendersThreshold uint8 `json:"senders_threshold,omitempty"` 37 | Signers []string `json:"signers,omitempty"` 38 | SnapshotHash string `json:"snapshot_hash,omitempty"` 39 | SnapshotAt *time.Time `json:"snapshot_at,omitempty"` 40 | State SafeUtxoState `json:"state,omitempty"` 41 | RawTransaction string `json:"raw_transaction"` 42 | Views []mixinnet.Key `json:"views,omitempty"` 43 | RevokedBy string `json:"revoked_by"` 44 | 45 | // TODO delete when asset_id is on 46 | Asset mixinnet.Hash `json:"asset,omitempty"` 47 | } 48 | ) 49 | 50 | func (c *Client) SafeCreateTransactionRequests(ctx context.Context, inputs []*SafeTransactionRequestInput) ([]*SafeTransactionRequest, error) { 51 | var resp []*SafeTransactionRequest 52 | if err := c.Post(ctx, "/safe/transaction/requests", inputs, &resp); err != nil { 53 | return nil, err 54 | } 55 | 56 | return resp, nil 57 | } 58 | 59 | func (c *Client) SafeCreateTransactionRequest(ctx context.Context, input *SafeTransactionRequestInput) (*SafeTransactionRequest, error) { 60 | requests, err := c.SafeCreateTransactionRequests(ctx, []*SafeTransactionRequestInput{input}) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return requests[0], nil 66 | } 67 | 68 | func (c *Client) SafeReadTransactionRequest(ctx context.Context, idOrHash string) (*SafeTransactionRequest, error) { 69 | var resp SafeTransactionRequest 70 | if err := c.Get(ctx, "/safe/transactions/"+idOrHash, nil, &resp); err != nil { 71 | return nil, err 72 | } 73 | 74 | return &resp, nil 75 | } 76 | 77 | func (c *Client) SafeSubmitTransactionRequests(ctx context.Context, inputs []*SafeTransactionRequestInput) ([]*SafeTransactionRequest, error) { 78 | var resp []*SafeTransactionRequest 79 | if err := c.Post(ctx, "/safe/transactions", inputs, &resp); err != nil { 80 | return nil, err 81 | } 82 | 83 | return resp, nil 84 | } 85 | 86 | func (c *Client) SafeSubmitTransactionRequest(ctx context.Context, input *SafeTransactionRequestInput) (*SafeTransactionRequest, error) { 87 | requests, err := c.SafeSubmitTransactionRequests(ctx, []*SafeTransactionRequestInput{input}) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | return requests[0], nil 93 | } 94 | -------------------------------------------------------------------------------- /safe_utxo.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 11 | "github.com/shopspring/decimal" 12 | ) 13 | 14 | const ( 15 | SafeUtxoStateUnspent SafeUtxoState = "unspent" 16 | SafeUtxoStateSigned SafeUtxoState = "signed" 17 | SafeUtxoStateSpent SafeUtxoState = "spent" 18 | ) 19 | 20 | type ( 21 | SafeUtxoState string 22 | 23 | SafeUtxo struct { 24 | OutputID string `json:"output_id,omitempty"` 25 | RequestID string `json:"request_id,omitempty"` 26 | TransactionHash mixinnet.Hash `json:"transaction_hash,omitempty"` 27 | OutputIndex uint8 `json:"output_index,omitempty"` 28 | KernelAssetID mixinnet.Hash `json:"kernel_asset_id,omitempty"` 29 | AssetID string `json:"asset_id,omitempty"` 30 | Amount decimal.Decimal `json:"amount,omitempty"` 31 | Mask mixinnet.Key `json:"mask,omitempty"` 32 | Keys []mixinnet.Key `json:"keys,omitempty"` 33 | InscriptionHash mixinnet.Hash `json:"inscription_hash,omitempty"` 34 | SendersHash string `json:"senders_hash,omitempty"` 35 | SendersThreshold uint8 `json:"senders_threshold,omitempty"` 36 | Senders []string `json:"senders,omitempty"` 37 | ReceiversHash mixinnet.Hash `json:"receivers_hash,omitempty"` 38 | ReceiversThreshold uint8 `json:"receivers_threshold,omitempty"` 39 | Receivers []string `json:"receivers,omitempty"` 40 | Extra string `json:"extra,omitempty"` 41 | State SafeUtxoState `json:"state,omitempty"` 42 | Sequence uint64 `json:"sequence,omitempty"` 43 | CreatedAt time.Time `json:"created_at,omitempty"` 44 | UpdatedAt time.Time `json:"updated_at,omitempty"` 45 | Signers []string `json:"signers,omitempty"` 46 | SignedBy string `json:"signed_by,omitempty"` 47 | SignedAt *time.Time `json:"signed_at,omitempty"` 48 | SpentAt *time.Time `json:"spent_at,omitempty"` 49 | } 50 | ) 51 | 52 | type SafeListUtxoOption struct { 53 | Members []string 54 | Threshold uint8 55 | Offset uint64 56 | Asset string 57 | Limit int 58 | Order string 59 | State SafeUtxoState 60 | IncludeSubWallets bool 61 | } 62 | 63 | func (c *Client) SafeListUtxos(ctx context.Context, opt SafeListUtxoOption) ([]*SafeUtxo, error) { 64 | params := make(map[string]string) 65 | if opt.Offset > 0 { 66 | params["offset"] = fmt.Sprint(opt.Offset) 67 | } 68 | 69 | if opt.Limit > 0 { 70 | params["limit"] = strconv.Itoa(opt.Limit) 71 | } 72 | 73 | if len(opt.Members) == 0 { 74 | opt.Members = []string{c.ClientID} 75 | } 76 | 77 | if opt.Threshold < 1 { 78 | opt.Threshold = 1 79 | } 80 | if int(opt.Threshold) > len(opt.Members) { 81 | return nil, errors.New("invalid members") 82 | } 83 | params["members"] = mixinnet.HashMembers(opt.Members) 84 | params["threshold"] = fmt.Sprint(opt.Threshold) 85 | 86 | if opt.IncludeSubWallets { 87 | params["app"] = c.ClientID 88 | } 89 | 90 | switch opt.Order { 91 | case "ASC", "DESC": 92 | default: 93 | opt.Order = "DESC" 94 | } 95 | params["order"] = opt.Order 96 | 97 | if opt.State != "" { 98 | params["state"] = string(opt.State) 99 | } 100 | 101 | if opt.Asset != "" { 102 | params["asset"] = opt.Asset 103 | } 104 | 105 | var utxos []*SafeUtxo 106 | if err := c.Get(ctx, "/safe/outputs", params, &utxos); err != nil { 107 | return nil, err 108 | } 109 | 110 | return utxos, nil 111 | } 112 | 113 | func (c *Client) SafeReadUtxo(ctx context.Context, id string) (*SafeUtxo, error) { 114 | uri := fmt.Sprintf("/safe/outputs/%s", id) 115 | 116 | var utxo SafeUtxo 117 | if err := c.Get(ctx, uri, nil, &utxo); err != nil { 118 | return nil, err 119 | } 120 | 121 | return &utxo, nil 122 | } 123 | 124 | func (c *Client) SafeReadUtxoByHash(ctx context.Context, hash mixinnet.Hash, index uint8) (*SafeUtxo, error) { 125 | id := fmt.Sprintf("%s:%d", hash.String(), index) 126 | return c.SafeReadUtxo(ctx, id) 127 | } 128 | -------------------------------------------------------------------------------- /safe_utxo_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSafeUtxo(t *testing.T) { 12 | ctx := context.Background() 13 | require := require.New(t) 14 | 15 | store := newKeystoreFromEnv(t) 16 | dapp, err := NewFromKeystore(&store.Keystore) 17 | require.NoError(err, "init bot client") 18 | 19 | utxos, err := dapp.SafeListUtxos(ctx, SafeListUtxoOption{ 20 | Members: []string{dapp.ClientID, "6a00a4bc-229e-3c39-978a-91d2d6c382bf"}, 21 | Limit: 50, 22 | Order: "ASC", 23 | State: SafeUtxoStateUnspent, 24 | }) 25 | require.NoError(err, "SafeListUtxos") 26 | 27 | bts, _ := json.MarshalIndent(utxos, "", " ") 28 | t.Log(string(bts)) 29 | } 30 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "io" 8 | "sort" 9 | ) 10 | 11 | const ( 12 | SessionPlatformIOS = "iOS" 13 | SessionPlatformAndroid = "Android" 14 | SessionPlatformDesktop = "Desktop" 15 | ) 16 | 17 | type Session struct { 18 | UserID string `json:"user_id,omitempty"` 19 | SessionID string `json:"session_id,omitempty"` 20 | PublicKey string `json:"public_key,omitempty"` 21 | Platform string `json:"platform,omitempty"` 22 | } 23 | 24 | func IsEncryptedMessageSupported(sessions []*Session) bool { 25 | for _, session := range sessions { 26 | if session.PublicKey == "" { 27 | return false 28 | } 29 | } 30 | 31 | return true 32 | } 33 | 34 | func GenerateSessionChecksum(sessions []*Session) string { 35 | ids := make([]string, len(sessions)) 36 | for i, session := range sessions { 37 | ids[i] = session.SessionID 38 | } 39 | 40 | if len(ids) == 0 { 41 | return "" 42 | } 43 | 44 | sort.Strings(ids) 45 | h := md5.New() 46 | for _, id := range ids { 47 | _, _ = io.WriteString(h, id) 48 | } 49 | sum := h.Sum(nil) 50 | return hex.EncodeToString(sum[:]) 51 | } 52 | 53 | func (c *Client) FetchSessions(ctx context.Context, ids []string) ([]*Session, error) { 54 | var sessions []*Session 55 | 56 | if err := c.Post(ctx, "/sessions/fetch", ids, &sessions); err != nil { 57 | return nil, err 58 | } 59 | 60 | return sessions, nil 61 | } 62 | -------------------------------------------------------------------------------- /sign.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/go-resty/resty/v2" 12 | "github.com/oxtoacart/bpool" 13 | ) 14 | 15 | var bufferPool = bpool.NewSizedBufferPool(16, 256) 16 | 17 | func SignRaw(method, uri string, body []byte) string { 18 | b := bufferPool.Get() 19 | defer bufferPool.Put(b) 20 | 21 | b.WriteString(method) 22 | b.WriteString(uri) 23 | b.Write(body) 24 | sum := sha256.Sum256(b.Bytes()) 25 | return hex.EncodeToString(sum[:]) 26 | } 27 | 28 | func SignRequest(r *http.Request) string { 29 | method := r.Method 30 | uri := trimURLHost(r.URL) 31 | 32 | var body []byte 33 | if r.GetBody != nil { 34 | if b, _ := r.GetBody(); b != nil { 35 | body, _ = io.ReadAll(b) 36 | _ = b.Close() 37 | } 38 | } 39 | 40 | return SignRaw(method, uri, body) 41 | } 42 | 43 | func SignResponse(r *resty.Response) string { 44 | method := r.Request.Method 45 | uri := trimURLHost(r.Request.RawRequest.URL) 46 | return SignRaw(method, uri, r.Body()) 47 | } 48 | 49 | func trimURLHost(u *url.URL) string { 50 | path := u.Path 51 | if path == "" || path == "/" { 52 | return "/" 53 | } 54 | 55 | uri := u.String() 56 | 57 | if idx := strings.Index(uri, path); idx >= 0 { 58 | uri = uri[idx:] 59 | } 60 | 61 | return uri 62 | } 63 | -------------------------------------------------------------------------------- /sign_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func Test_trimURLHost(t *testing.T) { 9 | type args struct { 10 | u *url.URL 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want string 16 | }{ 17 | { 18 | name: "url with path", 19 | args: args{u: parseURL("https://api.mixin.one/assets")}, 20 | want: "/assets", 21 | }, 22 | { 23 | name: "url without host", 24 | args: args{u: parseURL("/assets")}, 25 | want: "/assets", 26 | }, 27 | { 28 | name: "url without path", 29 | args: args{parseURL("https://api.mixin.one")}, 30 | want: "/", 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | if got := trimURLHost(tt.args.u); got != tt.want { 36 | t.Errorf("trimURLHost() = %v, want %v", got, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func parseURL(raw string) *url.URL { 43 | u, err := url.Parse(raw) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | return u 49 | } 50 | -------------------------------------------------------------------------------- /tip.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/sha256" 5 | 6 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 7 | ) 8 | 9 | const ( 10 | TIPVerify = "TIP:VERIFY:" 11 | TIPAddressAdd = "TIP:ADDRESS:ADD:" 12 | TIPAddressRemove = "TIP:ADDRESS:REMOVE:" 13 | TIPUserDeactivate = "TIP:USER:DEACTIVATE:" 14 | TIPEmergencyContactCreate = "TIP:EMERGENCY:CONTACT:CREATE:" 15 | TIPEmergencyContactRead = "TIP:EMERGENCY:CONTACT:READ:" 16 | TIPEmergencyContactRemove = "TIP:EMERGENCY:CONTACT:REMOVE:" 17 | TIPPhoneNumberUpdate = "TIP:PHONE:NUMBER:UPDATE:" 18 | TIPMultisigRequestSign = "TIP:MULTISIG:REQUEST:SIGN:" 19 | TIPMultisigRequestUnlock = "TIP:MULTISIG:REQUEST:UNLOCK:" 20 | TIPCollectibleRequestSign = "TIP:COLLECTIBLE:REQUEST:SIGN:" 21 | TIPCollectibleRequestUnlock = "TIP:COLLECTIBLE:REQUEST:UNLOCK:" 22 | TIPTransferCreate = "TIP:TRANSFER:CREATE:" 23 | TIPWithdrawalCreate = "TIP:WITHDRAWAL:CREATE:" 24 | TIPRawTransactionCreate = "TIP:TRANSACTION:CREATE:" 25 | TIPOAuthApprove = "TIP:OAUTH:APPROVE:" 26 | TIPProvisioningUpdate = "TIP:PROVISIONING:UPDATE:" 27 | TIPAppOwnershipTransfer = "TIP:APP:OWNERSHIP:TRANSFER:" 28 | TIPSequencerRegister = "SEQUENCER:REGISTER:" 29 | ) 30 | 31 | func (c *Client) EncryptTipPin(key mixinnet.Key, action string, params ...string) string { 32 | hash := sha256.New() 33 | hash.Write([]byte(action)) 34 | for _, p := range params { 35 | hash.Write([]byte(p)) 36 | } 37 | 38 | return c.EncryptPin(key.Sign(hash.Sum(nil)).String()) 39 | } 40 | -------------------------------------------------------------------------------- /transaction_input.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | func (c *Client) createGhostKeys(ctx context.Context, txVer uint8, inputs []*GhostInput, senders []string) ([]*GhostKeys, error) { 13 | // sort receivers 14 | for _, input := range inputs { 15 | sort.Strings(input.Receivers) 16 | } 17 | 18 | if txVer < mixinnet.TxVersionHashSignature { 19 | return c.BatchReadGhostKeys(ctx, inputs) 20 | } 21 | 22 | return c.SafeCreateGhostKeys(ctx, inputs, senders...) 23 | } 24 | 25 | type TransactionBuilder struct { 26 | *mixinnet.TransactionInput 27 | addr *MixAddress 28 | } 29 | 30 | type TransactionOutput struct { 31 | Address *MixAddress `json:"address,omitempty"` 32 | Amount decimal.Decimal `json:"amount,omitempty"` 33 | } 34 | 35 | func NewLegacyTransactionBuilder(utxos []*MultisigUTXO) *TransactionBuilder { 36 | b := &TransactionBuilder{ 37 | TransactionInput: &mixinnet.TransactionInput{ 38 | TxVersion: mixinnet.TxVersionLegacy, 39 | Hint: newUUID(), 40 | Inputs: make([]*mixinnet.InputUTXO, len(utxos)), 41 | }, 42 | } 43 | 44 | for i, utxo := range utxos { 45 | b.Inputs[i] = &mixinnet.InputUTXO{ 46 | Input: mixinnet.Input{ 47 | Hash: &utxo.TransactionHash, 48 | Index: uint8(utxo.OutputIndex), 49 | }, 50 | Asset: utxo.Asset(), 51 | Amount: utxo.Amount, 52 | } 53 | 54 | addr, err := NewMixAddress(utxo.Members, utxo.Threshold) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | if i == 0 { 60 | b.addr = addr 61 | } else if b.addr.String() != addr.String() { 62 | panic("invalid utxos") 63 | } 64 | } 65 | 66 | return b 67 | } 68 | 69 | func NewSafeTransactionBuilder(utxos []*SafeUtxo) *TransactionBuilder { 70 | b := &TransactionBuilder{ 71 | TransactionInput: &mixinnet.TransactionInput{ 72 | TxVersion: mixinnet.TxVersion, 73 | Hint: newUUID(), 74 | Inputs: make([]*mixinnet.InputUTXO, len(utxos)), 75 | }, 76 | } 77 | 78 | for i, utxo := range utxos { 79 | b.Inputs[i] = &mixinnet.InputUTXO{ 80 | Input: mixinnet.Input{ 81 | Hash: &utxo.TransactionHash, 82 | Index: utxo.OutputIndex, 83 | }, 84 | Asset: utxo.KernelAssetID, 85 | Amount: utxo.Amount, 86 | } 87 | 88 | addr, err := NewMixAddress(utxo.Receivers, utxo.ReceiversThreshold) 89 | if err != nil { 90 | panic(err) 91 | } 92 | 93 | if i == 0 { 94 | b.addr = addr 95 | } else if b.addr.String() != addr.String() { 96 | panic("invalid utxos") 97 | } 98 | } 99 | 100 | return b 101 | } 102 | 103 | func (c *Client) MakeTransaction(ctx context.Context, b *TransactionBuilder, outputs []*TransactionOutput) (*mixinnet.Transaction, error) { 104 | remain := b.TotalInputAmount() 105 | for _, output := range outputs { 106 | remain = remain.Sub(output.Amount) 107 | } 108 | 109 | if remain.IsPositive() { 110 | outputs = append(outputs, &TransactionOutput{ 111 | Address: b.addr, 112 | Amount: remain, 113 | }) 114 | } 115 | 116 | if err := c.AppendOutputsToInput(ctx, b, outputs); err != nil { 117 | return nil, err 118 | } 119 | 120 | return b.Build() 121 | } 122 | 123 | func (c *Client) AppendOutputsToInput(ctx context.Context, b *TransactionBuilder, outputs []*TransactionOutput) error { 124 | var ( 125 | ghostInputs []*GhostInput 126 | ghostOutputs []*mixinnet.Output 127 | ) 128 | 129 | for _, output := range outputs { 130 | txOutput := &mixinnet.Output{ 131 | Type: mixinnet.OutputTypeScript, 132 | Amount: mixinnet.IntegerFromDecimal(output.Amount), 133 | Script: mixinnet.NewThresholdScript(output.Address.Threshold), 134 | } 135 | 136 | index := uint8(len(b.Outputs)) 137 | if len(output.Address.xinMembers) > 0 { 138 | key := SafeCreateXinAddressGhostKeys(b.TxVersion, output.Address.xinMembers, index) 139 | txOutput.Mask = key.Mask 140 | txOutput.Keys = key.Keys 141 | } else if len(output.Address.uuidMembers) > 0 { 142 | ghostInputs = append(ghostInputs, &GhostInput{ 143 | Receivers: output.Address.Members(), 144 | Index: index, 145 | Hint: uuidHash([]byte(fmt.Sprintf("hint:%s;index:%d", b.Hint, index))), 146 | }) 147 | 148 | ghostOutputs = append(ghostOutputs, txOutput) 149 | } 150 | 151 | b.Outputs = append(b.Outputs, txOutput) 152 | } 153 | 154 | if len(ghostInputs) > 0 { 155 | keys, err := c.createGhostKeys(ctx, b.TxVersion, ghostInputs, b.addr.Members()) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | for i, key := range keys { 161 | output := ghostOutputs[i] 162 | output.Keys = key.Keys 163 | output.Mask = key.Mask 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /transaction_input_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/shopspring/decimal" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestBuildTransaction(t *testing.T) { 12 | ctx := context.Background() 13 | require := require.New(t) 14 | 15 | store := newKeystoreFromEnv(t) 16 | dapp, err := NewFromKeystore(&store.Keystore) 17 | require.NoError(err, "init bot client") 18 | 19 | t.Run("legacy-network", func(t *testing.T) { 20 | }) 21 | 22 | t.Run("safe-network", func(t *testing.T) { 23 | utxos, err := dapp.SafeListUtxos(ctx, SafeListUtxoOption{ 24 | Members: []string{dapp.ClientID}, 25 | Limit: 1, 26 | State: SafeUtxoStateUnspent, 27 | }) 28 | require.NoError(err, "SafeListUtxos") 29 | if len(utxos) == 0 { 30 | t.Log("empty unspent utxo") 31 | return 32 | } 33 | 34 | b := NewSafeTransactionBuilder(utxos) 35 | b.Memo = "TestSafeMakeTransaction" 36 | 37 | tx, err := dapp.MakeTransaction(ctx, b, []*TransactionOutput{ 38 | { 39 | Address: RequireNewMixAddress([]string{dapp.ClientID}, 1), 40 | Amount: decimal.New(1, -8), 41 | }, 42 | }) 43 | require.NoError(err, "MakeTransaction") 44 | 45 | raw, err := tx.Dump() 46 | require.NoError(err, "Dump") 47 | t.Log(raw) 48 | 49 | request, err := dapp.SafeCreateTransactionRequest(ctx, &SafeTransactionRequestInput{ 50 | RequestID: uuidHash([]byte(utxos[0].OutputID + ":SafeCreateTransactionRequest")), 51 | RawTransaction: raw, 52 | }) 53 | require.NoError(err, "SafeCreateTransactionRequest") 54 | err = SafeSignTransaction( 55 | tx, 56 | store.SpendKey, 57 | request.Views, 58 | 0, 59 | ) 60 | require.NoError(err, "SafeSignTransaction") 61 | 62 | signedRaw, err := tx.Dump() 63 | require.NoError(err, "tx.Dump") 64 | 65 | request1, err := dapp.SafeSubmitTransactionRequest(ctx, &SafeTransactionRequestInput{ 66 | RequestID: request.RequestID, 67 | RawTransaction: signedRaw, 68 | }) 69 | require.NoError(err, "SafeSubmitTransactionRequest") 70 | 71 | _, err = dapp.SafeReadTransactionRequest(ctx, request1.RequestID) 72 | require.NoError(err, "SafeReadTransactionRequest") 73 | 74 | t.Log(request1.TransactionHash) 75 | }) 76 | 77 | } 78 | -------------------------------------------------------------------------------- /transaction_signer.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "filippo.io/edwards25519" 5 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 6 | ) 7 | 8 | func SafeSignTransaction(tx *mixinnet.Transaction, spendKey mixinnet.Key, views []mixinnet.Key, k uint16) error { 9 | y, err := spendKey.ToScalar() 10 | if err != nil { 11 | return err 12 | } 13 | 14 | txHash, err := tx.TransactionHash() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | if tx.Signatures == nil { 20 | tx.Signatures = make([]map[uint16]*mixinnet.Signature, len(tx.Inputs)) 21 | } 22 | 23 | for idx, view := range views { 24 | x, err := view.ToScalar() 25 | if err != nil { 26 | panic(err) 27 | } 28 | t := edwards25519.NewScalar().Add(x, y) 29 | var key mixinnet.Key 30 | copy(key[:], t.Bytes()) 31 | sig := key.SignHash(txHash) 32 | 33 | if tx.Signatures[idx] == nil { 34 | tx.Signatures[idx] = make(map[uint16]*mixinnet.Signature) 35 | } 36 | 37 | tx.Signatures[idx][k] = &sig 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /turn.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Turn struct { 8 | URL string `json:"url"` 9 | Username string `json:"username"` 10 | Credential string `json:"credential"` 11 | } 12 | 13 | func (c *Client) ReadTurnServers(ctx context.Context) ([]*Turn, error) { 14 | var servers []*Turn 15 | if err := c.Get(ctx, "/turn", nil, &servers); err != nil { 16 | return nil, err 17 | } 18 | 19 | return servers, nil 20 | } 21 | -------------------------------------------------------------------------------- /url_scheme.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/url" 6 | "path" 7 | ) 8 | 9 | const ( 10 | Scheme = "mixin" 11 | ) 12 | 13 | type SendSchemeCategory = string 14 | 15 | const ( 16 | SendSchemeCategoryText SendSchemeCategory = "text" 17 | SendSchemeCategoryImage SendSchemeCategory = "image" 18 | SendSchemeCategoryContact SendSchemeCategory = "contact" 19 | SendSchemeCategoryAppCard SendSchemeCategory = "app_card" 20 | SendSchemeCategoryLive SendSchemeCategory = "live" 21 | SendSchemeCategoryPost SendSchemeCategory = "post" 22 | ) 23 | 24 | var URL = urlScheme{ 25 | host: "mixin.one", 26 | } 27 | 28 | type urlScheme struct { 29 | host string 30 | } 31 | 32 | func (urlScheme) Users(userID string) string { 33 | u := url.URL{ 34 | Scheme: Scheme, 35 | Host: "users", 36 | Path: userID, 37 | } 38 | 39 | return u.String() 40 | } 41 | 42 | func (urlScheme) Transfer(userID string) string { 43 | u := url.URL{ 44 | Scheme: Scheme, 45 | Host: "transfer", 46 | Path: userID, 47 | } 48 | 49 | return u.String() 50 | } 51 | 52 | func (urlScheme) Pay(input *TransferInput) string { 53 | q := url.Values{} 54 | q.Set("asset", input.AssetID) 55 | q.Set("trace", input.TraceID) 56 | q.Set("amount", input.Amount.String()) 57 | q.Set("recipient", input.OpponentID) 58 | q.Set("memo", input.Memo) 59 | 60 | u := url.URL{ 61 | Scheme: Scheme, 62 | Host: "pay", 63 | RawQuery: q.Encode(), 64 | } 65 | 66 | return u.String() 67 | } 68 | 69 | func (s urlScheme) SafePay(input *TransferInput) string { 70 | q := url.Values{} 71 | 72 | if input.AssetID != "" { 73 | q.Set("asset", input.AssetID) 74 | } 75 | 76 | if input.TraceID != "" { 77 | q.Set("trace", input.TraceID) 78 | } 79 | 80 | if input.Amount.IsPositive() { 81 | q.Set("amount", input.Amount.String()) 82 | } 83 | 84 | if input.Memo != "" { 85 | q.Set("memo", input.Memo) 86 | } 87 | 88 | u := url.URL{ 89 | Scheme: "https", 90 | Host: s.host, 91 | Path: "/pay", 92 | RawQuery: q.Encode(), 93 | } 94 | 95 | if addr, err := NewMixAddress(input.OpponentMultisig.Receivers, input.OpponentMultisig.Threshold); err == nil { 96 | u.Path = path.Join(u.Path, addr.String()) 97 | } else { 98 | u.Path = path.Join(u.Path, input.OpponentID) 99 | } 100 | 101 | return u.String() 102 | } 103 | 104 | func (urlScheme) Codes(code string) string { 105 | u := url.URL{ 106 | Scheme: Scheme, 107 | Host: "codes", 108 | Path: code, 109 | } 110 | 111 | return u.String() 112 | } 113 | 114 | func (urlScheme) Snapshots(snapshotID, traceID string) string { 115 | u := url.URL{ 116 | Scheme: Scheme, 117 | Host: "snapshots", 118 | } 119 | 120 | if snapshotID != "" { 121 | u.Path = snapshotID 122 | } 123 | 124 | if traceID != "" { 125 | query := url.Values{} 126 | query.Set("trace", traceID) 127 | u.RawQuery = query.Encode() 128 | } 129 | 130 | return u.String() 131 | } 132 | 133 | // Conversations scheme of a conversation 134 | // 135 | // userID optional, for user conversation only, if there's not conversation with the user, messenger will create the conversation first 136 | // 137 | // https://developers.mixin.one/docs/schema#open-an-conversation 138 | func (urlScheme) Conversations(conversationID, userID string) string { 139 | u := url.URL{ 140 | Scheme: Scheme, 141 | Host: "conversations", 142 | } 143 | 144 | if conversationID != "" { 145 | u.Path = conversationID 146 | } 147 | 148 | if userID != "" { 149 | query := url.Values{} 150 | query.Set("user", userID) 151 | u.RawQuery = query.Encode() 152 | } 153 | 154 | return u.String() 155 | } 156 | 157 | // Apps scheme of an app 158 | // 159 | // appID required, userID of an app 160 | // action optional, action about this scheme, default is "open" 161 | // params optional, parameters of any name or type can be passed when opening the bot homepage to facilitate the development of features like invitation codes, visitor tracking, etc 162 | // 163 | // https://developers.mixin.one/docs/schema#popups-bot-profile 164 | func (urlScheme) Apps(appID, action string, params map[string]string) string { 165 | u := url.URL{ 166 | Scheme: Scheme, 167 | Host: "apps", 168 | } 169 | 170 | if appID != "" { 171 | u.Path = appID 172 | } 173 | 174 | query := url.Values{} 175 | if action != "" { 176 | query.Set("action", action) 177 | } else { 178 | query.Set("action", "open") 179 | } 180 | for k, v := range params { 181 | query.Set(k, v) 182 | } 183 | u.RawQuery = query.Encode() 184 | 185 | return u.String() 186 | } 187 | 188 | // Send scheme of a share 189 | // 190 | // category required, category of shared content 191 | // data required, shared content 192 | // conversationID optional, If you specify conversation and it is the conversation of the user's current session, the confirmation box shown above will appear, the message will be sent after the user clicks the confirmation; if the conversation is not specified or is not the conversation of the current session, an interface where the user chooses which session to share with will show up. 193 | // 194 | // https://developers.mixin.one/docs/schema#sharing 195 | func (urlScheme) Send(category SendSchemeCategory, data []byte, conversationID string) string { 196 | u := url.URL{ 197 | Scheme: Scheme, 198 | Host: "send", 199 | } 200 | query := url.Values{} 201 | query.Set("category", category) 202 | if len(data) > 0 { 203 | query.Set("data", url.QueryEscape(base64.StdEncoding.EncodeToString(data))) 204 | } 205 | if conversationID != "" { 206 | query.Set("conversation", conversationID) 207 | } 208 | u.RawQuery = query.Encode() 209 | 210 | return u.String() 211 | } 212 | -------------------------------------------------------------------------------- /url_scheme_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shopspring/decimal" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestUrlScheme_Transfer(t *testing.T) { 11 | userID := newUUID() 12 | url := URL.Transfer(userID) 13 | assert.Equal(t, "mixin://transfer/"+userID, url) 14 | } 15 | 16 | func TestUrlScheme_SafePay(t *testing.T) { 17 | input := &TransferInput{ 18 | AssetID: newUUID(), 19 | OpponentID: newUUID(), 20 | Amount: decimal.NewFromInt(100), 21 | TraceID: newUUID(), 22 | Memo: "test", 23 | } 24 | 25 | url := URL.SafePay(input) 26 | t.Log(url) 27 | } 28 | 29 | func TestUrlScheme_Apps(t *testing.T) { 30 | t.Run("default action", func(t *testing.T) { 31 | appID := newUUID() 32 | action := "" 33 | url := URL.Apps(appID, action, nil) 34 | assert.Equal(t, "mixin://apps/"+appID+"?action=open", url) 35 | }) 36 | t.Run("specify action", func(t *testing.T) { 37 | appID := newUUID() 38 | action := "close" 39 | url := URL.Apps(appID, action, nil) 40 | assert.Equal(t, "mixin://apps/"+appID+"?action="+action, url) 41 | }) 42 | t.Run("specify params", func(t *testing.T) { 43 | appID := newUUID() 44 | action := "" 45 | params := map[string]string{"k1": "v1", "k2": "v2"} 46 | url := URL.Apps(appID, action, params) 47 | assert.Contains(t, url, "mixin://apps/"+appID+"?action=open") 48 | assert.Contains(t, url, "k1=v1") 49 | assert.Contains(t, url, "k2=v2") 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestCreateUser(t *testing.T) { 13 | require := require.New(t) 14 | 15 | store := newKeystoreFromEnv(t) 16 | 17 | botClient, err := NewFromKeystore(&store.Keystore) 18 | require.NoError(err, "init bot client") 19 | 20 | // create a user with a RSA key 21 | rsaPriKey, _ := rsa.GenerateKey(rand.Reader, 1024) 22 | user, keystore, err := botClient.CreateUser(context.TODO(), rsaPriKey, "name-rsa") 23 | require.NoError(err, "create a user with a RSA key") 24 | 25 | rsaUserClient, err := NewFromKeystore(keystore) 26 | require.NoError(err, "RSA user client") 27 | me, err := rsaUserClient.UserMe(context.TODO()) 28 | require.NoError(err, "read the RSA user") 29 | require.Equal(me.UserID, user.UserID, "user ids should be same") 30 | err = rsaUserClient.ModifyPin(context.TODO(), "", "111111") 31 | require.NoError(err, "the RSA user modifies pin") 32 | 33 | ed25519PriKey := GenerateEd25519Key() 34 | user, keystore, err = botClient.CreateUser(context.TODO(), ed25519PriKey, "name-ed25519") 35 | require.NoError(err, "create a user with a Ed25519 key") 36 | 37 | ed25519UserClient, err := NewFromKeystore(keystore) 38 | require.NoError(err, "Ed25519 user client") 39 | me, err = ed25519UserClient.UserMe(context.TODO()) 40 | require.NoError(err, "read the Ed25519 user") 41 | require.Equal(me.UserID, user.UserID, "user ids should be same") 42 | err = ed25519UserClient.ModifyPin(context.TODO(), "", "222222") 43 | require.NoError(err, "the Ed25519 user modifies pin") 44 | } 45 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package mixin 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/rand" 6 | "encoding/binary" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/gofrs/uuid/v5" 11 | ) 12 | 13 | func newUUID() string { 14 | return uuid.Must(uuid.NewV4()).String() 15 | } 16 | 17 | func UniqueConversationID(userID, recipientID string) string { 18 | minID, maxID := userID, recipientID 19 | if strings.Compare(userID, recipientID) > 0 { 20 | maxID, minID = userID, recipientID 21 | } 22 | 23 | return uuidHash([]byte(minID + maxID)) 24 | } 25 | 26 | func uuidHash(b []byte) string { 27 | h := md5.New() 28 | h.Write(b) 29 | sum := h.Sum(nil) 30 | sum[6] = (sum[6] & 0x0f) | 0x30 31 | sum[8] = (sum[8] & 0x3f) | 0x80 32 | return uuid.FromBytesOrNil(sum).String() 33 | } 34 | 35 | func RandomPin() string { 36 | var b [8]byte 37 | _, err := rand.Read(b[:]) 38 | if err != nil { 39 | panic(err) 40 | } 41 | c := binary.LittleEndian.Uint64(b[:]) % 1000000 42 | if c < 100000 { 43 | c = 100000 + c 44 | } 45 | 46 | return strconv.FormatUint(c, 10) 47 | } 48 | 49 | func RandomTraceID() string { 50 | return newUUID() 51 | } 52 | --------------------------------------------------------------------------------