├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── mtg ├── README.md ├── action.go ├── cache.go ├── config.go ├── deposit.go ├── drain.go ├── example.toml ├── group.go ├── hack.go ├── iteration.go ├── mtg_test.go ├── output.go ├── rpc.go ├── schema.sql ├── serialize.go ├── sqlite3.go ├── store.go ├── transaction.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.abi 3 | *.wasm 4 | generated.go 5 | multisig 6 | multisig.1 7 | multisig.2 8 | multisig.3 9 | sample/configs/yml.go 10 | sample/configs/yml.go.1 11 | sample/configs/yml.go.2 12 | sample/configs/yml.go.3 13 | sample/configs/yml.go.1.production 14 | sample/configs/yml.go.2.production 15 | sample/configs/yml.go.3.production 16 | sample/configs/doc 17 | .DS_Store 18 | config.toml 19 | local.sh 20 | deploy.sh 21 | /mvm/mvm 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mixin Trusted Group 2 | 3 | Mixin Kernel is a badly simple distributed ledger which handles only basic UTXO transactions, no smart or stupid contracts, not even scripts complicated as Bitcoin. This simplicity makes Mixin Kernel the fastest decentralized solution for transferring digital assets. In the original whitepaper we proposed the Domain Extensions to make trusted computation possible for Mixin Network, now two years later, with the experience in building trusted financial systems, I have another proposal, a multisig group. 4 | 5 | I prefer to call it Mixin Trusted Group or MTG. The old Domain Extensions proposal is very flexible and has the potential to support endless of features to fulfill most of the real life needs. However, opposed to the simplicity of Mixin Kernel, Domain Extensions' flexibility prevent them from being in used, and indeed they are over complicated to implement correctly, thus sacrifice security without the governance of the strong Mixin Kernel PoS ledger. The Mixin Trusted Group is the result of simplifying Domain Extensions, it reduces the feature set to a multisig custodian, and is an open source program runs by several selected participants. 6 | 7 | Unlike every smart contract is executed by the huge state machine in all Ethereum or EOS nodes, MTG is only running by nodes selected by the program respectively. The solution is very similar to what those Ethereum folks have been planning for years, sharding Ethereum, every MTG is as a shard to Ethereum, and it's also interesting that MTG is a second layer solution to Mixin Kernel, while the Kernel is already a second layer to many other distributed ledgers like Bitcoin or Ethereum. 8 | 9 | ## Transaction 10 | 11 | To make Mixin Trusted Group easier to implement, the program may use the API provided by Mixin Messenger, to loop all their multisig transactions. For every UTXO belongs to their multisig group, they should save the UTXO in their local storage to make it easier for further query, then they do whatever they need to do with the memo associated with UTXO. A simple decentralized AMM similar to Uniswap could be implemented in below Golang snippet. 12 | 13 | ```golang 14 | // this is a simple Mixin Trusted Group to do decentralized AMM 15 | checkpoint := readCheckPointFromLocalStorage() 16 | for { 17 | // loop all the multisig UTXOs belong to the group 18 | path := fmt.Sprintf("/multisigs/outputs?members=%s&threshold=%d&offset=%s&limit=100", members, threshold, checkpoint) 19 | utxos := requestAPI("GET", path) 20 | 21 | for _, utxo := range utxos { 22 | // parse the UTXO to make sure it's valid 23 | res := parseUTXO(utxo) 24 | if !res.ValidForThisGroup { 25 | // also good to refund it if possible 26 | continue 27 | } 28 | // save the UTXO to local storage for further query 29 | writeUTXOToLocalStorage(utxo) 30 | 31 | switch res.Action { 32 | case "ADD-LIQUIDITY": 33 | // do liquidity calculation and return the liquidity provider token 34 | lp := doAddLiquidity(utxo.AssetId, utxo.Amount, res.ToAssetId) 35 | // the LP token also managed by MTG, send LP to the UTXO sender 36 | multi := signMultisigRequest(res.FromUserId, lp.AssetId, lp.Amount) 37 | // if the multisig request finished by enough signers, send out 38 | if len(multi.Signers) == utxo.Threshold { 39 | sendRawTransaction(multi.Raw) 40 | } 41 | case "SWAP": 42 | // do swap calculation and return the amount swapped out 43 | amount := doSwap(utxo.AsestId, utxo.Amount, res.ToAssetId) 44 | // the swapped asset also managed by MTG 45 | multi := signMultisigRequest(res.FromUserId, utxo.AssetId, amount) 46 | // if the multisig request finished by enough signers, send out 47 | if len(multi.Signers) == utxo.Threshold { 48 | sendRawTransaction(multi.Raw) 49 | } 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | ## Evolution 56 | 57 | The group should be able to operate when some nodes leave, and could allow new nodes joining. Whenever a node leaves or joins the group, an evolution happens. After an evolution, the old group enters the maintenance mode, and all further transactions should only be handled by the new group. There could be multiple old groups if multiple evolutions happen, and the group members can decide how many old groups to maintain. A maintenance group do two kind of works, notice users of the evolution and transfer old UTXOs to the new group. 58 | 59 | ```golang 60 | // the maintenance group transfer old UTXOs to the new group 61 | for { 62 | for _, utxo := range allOldLocalUTXOs { 63 | sendOldUTXOtoCurrentGroup(utxo) 64 | } 65 | } 66 | 67 | // the maintenance group notice users of the evolution 68 | checkpoint := readCheckPointFromLocalStorage(group) 69 | for { 70 | // loop all the multisig UTXOs belong to the group 71 | path := fmt.Sprintf("/multisigs/outputs?members=%s&threshold=%d&offset=%s&limit=100", members, threshold, checkpoint) 72 | utxos := requestAPI("GET", path) 73 | 74 | for _, utxo := range utxos { 75 | // make the refund transaction with evolution memo 76 | refundUTXOWithEvolutionMemo(utxo) 77 | } 78 | } 79 | ``` 80 | 81 | After a user receives a refund with an evolution memo, the user should get the new MTG information to retry their transactions. 82 | 83 | ## Governance 84 | 85 | After the program pass all tests, the owner should select some trusted nodes to help them run the program. The selected nodes should do full audit of the code to make sure its security, then the nodes will run the program in isolated secure environment, after all selected up, a Mixin Trusted Group is running. Thereafter, all users of the MTG can ensure their cryptocurrencies won't be stolen by anyone unless most of these nodes are cheaters, they can safely use the MTG like they trust an Ethereum smart contract. Compared to Ethereum smart contract, a Mixin Trusted Group is more likely to be audited by experts, and more importantly, MTG is always faster and cheaper to use than any smart contracts. 86 | 87 | Finally those selected nodes running Mixin Trusted Groups will get continuous income from the Group owners as the nodes spent their efforts on ensuring the security of their programs. Nodes make money, and Groups are trusted by people and they use them more and the groups make more money, and people have their money secured, all benefit. 88 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MixinNetwork/trusted-group 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/MixinNetwork/mixin v0.18.22 7 | github.com/fox-one/mixin-sdk-go/v2 v2.0.10 8 | github.com/gofrs/uuid/v5 v5.3.0 9 | github.com/mattn/go-sqlite3 v1.14.24 10 | github.com/pelletier/go-toml v1.9.5 11 | github.com/shopspring/decimal v1.4.0 12 | github.com/stretchr/testify v1.10.0 13 | ) 14 | 15 | require ( 16 | filippo.io/edwards25519 v1.1.0 // indirect 17 | github.com/btcsuite/btcutil v1.0.2 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/fox-one/msgpack v1.0.0 // indirect 20 | github.com/go-resty/resty/v2 v2.16.3 // indirect 21 | github.com/gofrs/uuid v4.4.0+incompatible // 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.3 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 26 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/vmihailenco/tagparser v0.1.2 // indirect 29 | github.com/zeebo/blake3 v0.2.4 // indirect 30 | golang.org/x/crypto v0.32.0 // indirect 31 | golang.org/x/net v0.34.0 // indirect 32 | golang.org/x/sync v0.10.0 // indirect 33 | golang.org/x/sys v0.29.0 // indirect 34 | google.golang.org/appengine v1.6.8 // indirect 35 | google.golang.org/protobuf v1.36.2 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 3 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/MixinNetwork/mixin v0.18.22 h1:cj1FRPH2Hj3PkQYnVYngZVnrrZv27gxVlgujVwBhhkk= 6 | github.com/MixinNetwork/mixin v0.18.22/go.mod h1:elY5L05s8R63ejjY/9+Lsq8h+rHw1s7yzXVmLzkZqBA= 7 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 8 | github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= 9 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 10 | github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 11 | github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= 12 | github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= 13 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 14 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 15 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 16 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 17 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 18 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 19 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 20 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 21 | github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 26 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 27 | github.com/fox-one/mixin-sdk-go/v2 v2.0.10 h1:U0aOCCsZOM3xSGYnZWcEanDPp28EoZLIC7CE8uY1idQ= 28 | github.com/fox-one/mixin-sdk-go/v2 v2.0.10/go.mod h1:3oaTbgw3ERL7UVi5E40NenQ16EkBVV7X++brLM1uWqU= 29 | github.com/fox-one/msgpack v1.0.0 h1:atr4La29WdMPCoddlRAPK2e1yhBJ2cEFF+2X93KY5Vs= 30 | github.com/fox-one/msgpack v1.0.0/go.mod h1:Gf/g5JQGPkB0JrQvfxCu8ZXm4jqXsCPe89mFe8i3vms= 31 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 32 | github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= 33 | github.com/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E= 34 | github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 35 | github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= 36 | github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 37 | github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk= 38 | github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= 39 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 40 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 41 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 42 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 43 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 44 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 45 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 46 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 47 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 48 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 49 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 50 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 51 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 52 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 53 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 54 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 55 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 56 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 57 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 58 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 59 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 60 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 61 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 62 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 63 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 65 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 66 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 67 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 68 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 69 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 70 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 71 | github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= 72 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 73 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 74 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 75 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 76 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 77 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 78 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 79 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 80 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 81 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 82 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 83 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 84 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 85 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 86 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 87 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 88 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 91 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 92 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 93 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 94 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 95 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 96 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 97 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 98 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 99 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 100 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 101 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 102 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 103 | github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= 104 | github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 105 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 106 | github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 107 | github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= 108 | github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= 109 | github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= 110 | github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= 111 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 112 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 113 | golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 114 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 115 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 116 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 117 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 118 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 119 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 120 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 121 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 122 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 123 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 124 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 125 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 126 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 127 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 128 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 129 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 130 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 131 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 132 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 133 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 134 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 135 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 136 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 137 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 138 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 139 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 140 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 141 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 142 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 143 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 144 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 145 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 146 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 148 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 149 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 150 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 151 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 152 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 153 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 154 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 155 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 156 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 160 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 163 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 165 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 166 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 167 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 168 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 169 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 170 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 171 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 172 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 173 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 174 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 175 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 176 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 177 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 178 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 179 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 180 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 181 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 182 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 183 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 184 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 185 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 186 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 187 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 188 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 189 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 190 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 191 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 192 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 193 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 194 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 195 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 196 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 197 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 198 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 199 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 200 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 201 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 202 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 203 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 204 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 205 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 206 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 207 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 208 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 209 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 210 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 211 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 212 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 213 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 214 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 215 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 216 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 217 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 218 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 219 | google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= 220 | google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 221 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 222 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 223 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 224 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 225 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 226 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 227 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 228 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 229 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 230 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 231 | -------------------------------------------------------------------------------- /mtg/README.md: -------------------------------------------------------------------------------- 1 | # MTG 2 | 3 | This module can bootstrap a MTG application very effortlessly, it's as simple as a few lines of code. 4 | 5 | ```golang 6 | func (rw *RefundWorker) ProcessOutput(ctx context.Context, out *mtg.Output) { 7 | receivers := []string{out.Sender} 8 | traceId := mixin.UniqueConversationID(out.UTXOID, "refund") 9 | err := rw.grp.BuildTransaction(ctx, out.AssetID, receivers, 1, out.Amount.String(), "refund", traceId) 10 | if err != nil { 11 | panic(err) 12 | } 13 | } 14 | 15 | group, _ := mtg.BuildGroup(ctx, db, conf) 16 | rw := NewRefundrWorker(ctx, group, conf) 17 | group.AddWorker(rw) 18 | group.Run(ctx) 19 | ``` 20 | 21 | The group will call every workers added, and the worker just needs to implement the `ProcessOutput` interface. The code above is a very simple worker that refunds all the payments received. 22 | -------------------------------------------------------------------------------- /mtg/action.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "fmt" 8 | 9 | "github.com/MixinNetwork/mixin/common" 10 | "github.com/MixinNetwork/mixin/crypto" 11 | "github.com/MixinNetwork/mixin/logger" 12 | ) 13 | 14 | const ( 15 | ActionStateInitial ActionState = 10 16 | ActionStateDone ActionState = 11 17 | ActionStateRestorable ActionState = 12 18 | ) 19 | 20 | type ActionState int 21 | 22 | type Action struct { 23 | ActionState ActionState 24 | restoreSequence uint64 25 | 26 | UnifiedOutput 27 | group *Group 28 | consumed map[string]uint64 29 | } 30 | 31 | var actionCols = []string{"output_id", "transaction_hash", "action_state", "sequence", "restore_sequence"} 32 | 33 | var actionJoinCols = []string{"actions.output_id", "actions.transaction_hash", "action_state", "actions.sequence", "restore_sequence", "request_id", "output_index", "asset_id", "kernel_asset_id", "amount", "senders_threshold", "senders", "receivers_threshold", "extra", "state", "created_at", "updated_at", "signers", "signed_by", "trace_id", "app_id"} 34 | 35 | func (a *Action) values() []any { 36 | return []any{a.OutputId, a.TransactionHash, a.ActionState, a.Sequence, a.restoreSequence} 37 | } 38 | 39 | func actionFromRow(row Row) (*Action, error) { 40 | var a Action 41 | err := row.Scan(&a.OutputId, &a.TransactionHash, &a.ActionState, &a.Sequence, &a.restoreSequence) 42 | if err == sql.ErrNoRows { 43 | return nil, nil 44 | } 45 | return &a, err 46 | } 47 | 48 | func actionJoinFromRow(row Row) (*Action, error) { 49 | var a Action 50 | var senders, signers string 51 | err := row.Scan(&a.OutputId, &a.TransactionHash, &a.ActionState, &a.Sequence, &a.restoreSequence, &a.TransactionRequestId, &a.OutputIndex, &a.AssetId, &a.KernelAssetId, &a.Amount, &a.SendersThreshold, &senders, &a.ReceiversThreshold, &a.Extra, &a.State, &a.SequencerCreatedAt, &a.updatedAt, &signers, &a.SignedBy, &a.TraceId, &a.AppId) 52 | if err == sql.ErrNoRows { 53 | return nil, nil 54 | } 55 | a.Senders = SplitIds(senders) 56 | a.Signers = SplitIds(signers) 57 | return &a, err 58 | } 59 | 60 | func (a *Action) TestAttachActionToGroup(g *Group) { 61 | a.group = g 62 | a.consumed = make(map[string]uint64) 63 | } 64 | 65 | func ReplayCheck(a *Action, txs1, txs2 []*Transaction, asset1, asset2 string) { 66 | if asset1 != asset2 { 67 | err := fmt.Errorf("action %s compaction asset %s => %s", a.OutputId, asset1, asset2) 68 | panic(err) 69 | } 70 | b1 := SerializeTransactions(txs1) 71 | b2 := SerializeTransactions(txs2) 72 | if !bytes.Equal(b1, b2) { 73 | err := fmt.Errorf("action %s serialization %x => %x", a.OutputId, b1, b2) 74 | panic(err) 75 | } 76 | } 77 | 78 | func (grp *Group) checkCompactionTransaction(ctx context.Context, action *Action) (*Transaction, bool) { 79 | ver, err := grp.ReadKernelTransactionUntilSufficient(ctx, action.TransactionHash) 80 | if err != nil { 81 | panic(err) 82 | } 83 | if ver.DepositData() != nil { 84 | d, err := grp.readOutputDepositUntilSufficient(ctx, action.OutputId) 85 | if err != nil { 86 | panic(err) 87 | } 88 | appId := grp.FindAppByEntry(DepositEntry{ 89 | Destination: d.Destination, 90 | Tag: d.Tag, 91 | }.UniqueKey()) 92 | if appId == "" { 93 | appId = grp.GroupId 94 | } 95 | if appId != action.AppId { 96 | panic(action.OutputId) 97 | } 98 | return nil, false 99 | } 100 | appId, _ := DecodeMixinExtraHEX(action.Extra) 101 | if appId == "" { 102 | appId = grp.GroupId 103 | } 104 | if appId != action.AppId { 105 | panic(action.OutputId) 106 | } 107 | 108 | appId, err = grp.checkMTGTransaction(ctx, ver) 109 | if err != nil { 110 | panic(err) 111 | } 112 | if appId == "" { 113 | return nil, false 114 | } 115 | hash, err := crypto.HashFromString(action.TransactionHash) 116 | if err != nil { 117 | panic(err) 118 | } 119 | tx, err := grp.store.ReadTransactionByHash(ctx, hash) 120 | if err != nil { 121 | panic(err) 122 | } 123 | return tx, true 124 | } 125 | 126 | // actions queue is all the utxos ordered by their sequence 127 | func (grp *Group) handleActionsQueue(ctx context.Context) error { 128 | as, err := grp.store.ListActions(ctx, ActionStateInitial, 16) 129 | logger.Verbosef("Group.ListActions() => %d %v", len(as), err) 130 | if err != nil { 131 | return fmt.Errorf("store.ListInitialActions() => %v", err) 132 | } 133 | for _, a := range as { 134 | tx, isMTG := grp.checkCompactionTransaction(ctx, a) 135 | if isMTG && tx == nil { 136 | return nil 137 | } 138 | if tx != nil && tx.compaction { 139 | return grp.store.RestoreAction(ctx, a, tx) 140 | } 141 | 142 | wkr := grp.FindWorker(a.AppId) 143 | if wkr == nil { 144 | err = grp.store.FinishAction(ctx, a.OutputId, ActionStateDone, nil) 145 | if err != nil { 146 | return fmt.Errorf("store.FinishAction(%s) => %v", a.OutputId, err) 147 | } 148 | continue 149 | } 150 | 151 | if a.restoreSequence > a.Sequence { 152 | a.Sequence = a.restoreSequence 153 | } 154 | a.group = grp 155 | a.consumed = make(map[string]uint64) 156 | txs, compactionAsset := wkr.ProcessOutput(ctx, a) 157 | if grp.debug { 158 | a.consumed = make(map[string]uint64) 159 | txs2, compactionAsset2 := wkr.ProcessOutput(ctx, a) 160 | ReplayCheck(a, txs, txs2, compactionAsset, compactionAsset2) 161 | } 162 | 163 | state := ActionStateDone 164 | if compactionAsset != "" && len(txs) == 0 { 165 | t, err := grp.buildCompactionTransaction(ctx, compactionAsset, a) 166 | if err != nil { 167 | return fmt.Errorf("group.buildCompactionTransaction(%s %v) => %v", compactionAsset, a, err) 168 | } 169 | state = ActionStateRestorable 170 | txs = []*Transaction{t} 171 | } else if compactionAsset != "" { 172 | return fmt.Errorf("invalid compactionAsset: %s", compactionAsset) 173 | } 174 | 175 | err = a.attachTxsConsumed(ctx, txs) 176 | if err != nil { 177 | return fmt.Errorf("group.attachTxsConsumed(%v) => %v", a, err) 178 | } 179 | err = grp.checkTransactions(ctx, a, txs) 180 | if err != nil { 181 | return fmt.Errorf("group.checkTransactions(%v) => %v", a, err) 182 | } 183 | 184 | err = grp.store.FinishAction(ctx, a.OutputId, state, txs) 185 | if err != nil { 186 | return fmt.Errorf("store.FinishAction(%s %d) => %v", a.OutputId, state, err) 187 | } 188 | } 189 | return nil 190 | } 191 | 192 | func (grp *Group) checkTransactions(ctx context.Context, act *Action, txs []*Transaction) error { 193 | totalAmount := make(map[string]common.Integer) 194 | for _, t := range txs { 195 | err := t.check(ctx, act) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | amount, ok := totalAmount[t.AssetId] 201 | if !ok { 202 | amount = common.NewInteger(0) 203 | } 204 | totalAmount[t.AssetId] = amount.Add(common.NewIntegerFromString(t.Amount)) 205 | } 206 | 207 | for asset, amount := range totalAmount { 208 | outputs := grp.ListOutputsForAsset(ctx, act.AppId, asset, 0, act.Sequence, SafeUtxoStateUnspent, 0) 209 | total := common.NewInteger(0) 210 | for _, os := range outputs { 211 | total = total.Add(common.NewIntegerFromString(os.Amount.String())) 212 | } 213 | if total.Cmp(amount) < 0 { 214 | return fmt.Errorf("insufficient balance for asset %s: %s %s", asset, total.String(), amount.String()) 215 | } 216 | } 217 | return nil 218 | } 219 | 220 | func (action *Action) attachTxsConsumed(ctx context.Context, txs []*Transaction) error { 221 | for _, tx := range txs { 222 | if len(tx.consumedIds) == 0 { 223 | panic(fmt.Sprintf("tx %s has empty consumedIds", tx.TraceId)) 224 | } 225 | if len(tx.consumed) > 0 { 226 | if len(tx.consumed) != len(tx.consumedIds) { 227 | panic(tx.TraceId) 228 | } 229 | continue 230 | } 231 | outputs, err := action.group.store.listOutputs(ctx, tx.consumedIds) 232 | if err != nil { 233 | return err 234 | } 235 | for _, o := range outputs { 236 | if o.State != SafeUtxoStateUnspent { 237 | panic(fmt.Sprintf("invalid output %s state %s for tx %s", o.OutputId, o.State, tx.TraceId)) 238 | } 239 | if o.Sequence <= action.Sequence && o.Sequence >= action.consumed[tx.AssetId] { 240 | action.consumed[tx.AssetId] = o.Sequence 241 | } else { 242 | panic(fmt.Sprintf("invalid outputs sequence %d for action sequence %d or asset %s consumed %d", o.Sequence, action.Sequence, tx.AssetId, action.consumed[tx.AssetId])) 243 | } 244 | } 245 | tx.consumed = outputs 246 | } 247 | return nil 248 | } 249 | -------------------------------------------------------------------------------- /mtg/cache.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const cacheTTL = 24 * time.Hour 11 | 12 | func (s *SQLite3Store) ReadCache(ctx context.Context, k string) (string, error) { 13 | s.mutex.RLock() 14 | defer s.mutex.RUnlock() 15 | 16 | row := s.db.QueryRowContext(ctx, "SELECT value,created_at FROM caches WHERE key=?", k) 17 | var value string 18 | var createdAt time.Time 19 | err := row.Scan(&value, &createdAt) 20 | if err == sql.ErrNoRows { 21 | return "", nil 22 | } else if err != nil { 23 | return "", err 24 | } 25 | if createdAt.Add(cacheTTL).Before(time.Now()) { 26 | return "", nil 27 | } 28 | return value, nil 29 | } 30 | 31 | func (s *SQLite3Store) WriteCache(ctx context.Context, k, v string) error { 32 | s.mutex.Lock() 33 | defer s.mutex.Unlock() 34 | 35 | tx, err := s.db.BeginTx(ctx, nil) 36 | if err != nil { 37 | return err 38 | } 39 | defer rollBack(tx) 40 | 41 | threshold := time.Now().Add(-cacheTTL).UTC() 42 | _, err = tx.ExecContext(ctx, "DELETE FROM caches WHERE created_at %v %v\n", id, deposit, err) 64 | if err != nil { 65 | if CheckRetryableError(err) { 66 | time.Sleep(3 * time.Second) 67 | continue 68 | } 69 | if strings.Contains(err.Error(), "not found") { 70 | return nil, nil 71 | } 72 | } 73 | return deposit, err 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /mtg/drain.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/MixinNetwork/mixin/common" 11 | "github.com/MixinNetwork/mixin/logger" 12 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 13 | ) 14 | 15 | const ( 16 | outputsDrainingKey = "outputs-draining-checkpoint" 17 | ) 18 | 19 | func (grp *Group) drainOutputsFromNetwork(ctx context.Context, filter map[string]bool, batch int) { 20 | logger.Verbosef("Group.drainOutputsFromNetwork(%d)\n", batch) 21 | 22 | for { 23 | checkpoint, err := grp.readDrainingCheckpoint(ctx) 24 | if err != nil { 25 | time.Sleep(3 * time.Second) 26 | continue 27 | } 28 | outputs, err := grp.readSafeOutputsAsUnspent(ctx, grp.GetMembers(), uint8(grp.threshold), checkpoint, batch) 29 | logger.Verbosef("Group.readSafeOutputsAsUnspent(%d) => %d %v\n", checkpoint, len(outputs), err) 30 | if err != nil { 31 | time.Sleep(3 * time.Second) 32 | continue 33 | } 34 | 35 | checkpoint = grp.processSafeOutputs(ctx, filter, checkpoint, outputs) 36 | grp.writeDrainingCheckpoint(ctx, checkpoint) 37 | if len(outputs) < batch/2 { 38 | break 39 | } 40 | } 41 | } 42 | 43 | func (grp *Group) processSafeOutputs(ctx context.Context, filter map[string]bool, checkpoint uint64, outputs []*UnifiedOutput) uint64 { 44 | for _, utxo := range outputs { 45 | checkpoint = utxo.Sequence 46 | key := fmt.Sprintf("ACT:%s:%d", utxo.OutputId, utxo.Sequence) 47 | if filter[key] || utxo.Sequence < grp.epoch { 48 | continue 49 | } 50 | filter[key] = true 51 | grp.processSafeOutput(ctx, utxo) 52 | } 53 | return checkpoint 54 | } 55 | 56 | func (grp *Group) processSafeOutput(ctx context.Context, output *UnifiedOutput) { 57 | logger.Verbosef("Group.processSafeOutput(%v)\n", output) 58 | actionState := ActionStateInitial 59 | 60 | ver, err := grp.ReadKernelTransactionUntilSufficient(ctx, output.TransactionHash) 61 | if err != nil { 62 | panic(err) 63 | } 64 | vo := ver.Outputs[output.OutputIndex] 65 | if vo.Amount.Cmp(common.NewIntegerFromString(output.Amount.String())) != 0 { 66 | panic(output.OutputId) 67 | } 68 | if !output.checkId() { 69 | panic(output.OutputId) 70 | } 71 | 72 | appId, _ := DecodeMixinExtraBase64(string(ver.Extra)) 73 | if ver.DepositData() != nil { 74 | d, err := grp.readOutputDepositUntilSufficient(ctx, output.OutputId) 75 | if err != nil { 76 | panic(err) 77 | } 78 | appId = grp.FindAppByEntry(DepositEntry{ 79 | Destination: d.Destination, 80 | Tag: d.Tag, 81 | }.UniqueKey()) 82 | } 83 | if appId == "" { 84 | appId = grp.GroupId 85 | } 86 | output.AppId = appId 87 | 88 | appId, err = grp.checkChange(ctx, output, ver) 89 | if err != nil { 90 | panic(err) 91 | } 92 | if appId != "" { 93 | output.AppId = appId 94 | actionState = ActionStateDone 95 | } 96 | err = grp.store.WriteAction(ctx, output, actionState) 97 | if err != nil { 98 | panic(err) 99 | } 100 | } 101 | 102 | func (grp *Group) checkChange(ctx context.Context, output *UnifiedOutput, ver *common.VersionedTransaction) (string, error) { 103 | // we must always ensure there are at most 2 outputs, 104 | // and the last one is the change output 105 | if output.OutputIndex != 1 { 106 | return "", nil 107 | } 108 | return grp.checkMTGTransaction(ctx, ver) 109 | } 110 | 111 | func (grp *Group) checkMTGTransaction(ctx context.Context, ver *common.VersionedTransaction) (string, error) { 112 | var outputs []*UnifiedOutput 113 | for _, input := range ver.Inputs { 114 | output, err := grp.store.ReadOutputByHashAndIndex(ctx, input.Hash.String(), input.Index) 115 | if err != nil { 116 | return "", err 117 | } 118 | if output != nil { 119 | outputs = append(outputs, output) 120 | } 121 | } 122 | if len(outputs) == 0 { 123 | return "", nil 124 | } 125 | if len(outputs) != len(ver.Inputs) { 126 | panic(ver.PayloadHash().String()) 127 | } 128 | 129 | var appId string 130 | for _, output := range outputs { 131 | if output.AppId == "" { 132 | panic(output.TraceId) 133 | } 134 | if appId == "" { 135 | appId = output.AppId 136 | } 137 | if output.AppId != appId { 138 | panic(output.TraceId) 139 | } 140 | } 141 | return appId, nil 142 | } 143 | 144 | func (grp *Group) readDrainingCheckpoint(ctx context.Context) (uint64, error) { 145 | val, err := grp.store.ReadProperty(ctx, outputsDrainingKey) 146 | if err != nil || len(val) == 0 { 147 | return 0, err 148 | } 149 | return strconv.ParseUint(val, 10, 64) 150 | } 151 | 152 | func (grp *Group) writeDrainingCheckpoint(ctx context.Context, ckpt uint64) { 153 | err := grp.store.WriteProperty(ctx, outputsDrainingKey, fmt.Sprint(ckpt)) 154 | if err != nil { 155 | panic(err) 156 | } 157 | } 158 | 159 | func (grp *Group) readSafeOutputsAsUnspent(ctx context.Context, members []string, threshold uint8, offset uint64, limit int) ([]*UnifiedOutput, error) { 160 | params := make(map[string]string) 161 | if offset > 0 { 162 | params["offset"] = fmt.Sprint(offset) 163 | } 164 | if limit > 0 { 165 | params["limit"] = strconv.Itoa(limit) 166 | } 167 | if threshold < 1 { 168 | threshold = 1 169 | } 170 | if int(threshold) > len(members) { 171 | return nil, errors.New("invalid members") 172 | } 173 | params["members"] = mixinnet.HashMembers(members) 174 | params["threshold"] = fmt.Sprint(threshold) 175 | params["order"] = "ASC" 176 | 177 | var utxos []*UnifiedOutput 178 | if err := grp.mixin.Get(ctx, "/safe/outputs", params, &utxos); err != nil { 179 | return nil, err 180 | } 181 | for _, o := range utxos { 182 | o.State = SafeUtxoStateUnspent 183 | } 184 | return utxos, nil 185 | } 186 | -------------------------------------------------------------------------------- /mtg/example.toml: -------------------------------------------------------------------------------- 1 | store-dir = "/tmp" 2 | project = "mtg-test" 3 | 4 | [app] 5 | app-id = "094ac88f-4671-3976-b60a-09064f1811e8" 6 | session-id = "194ac88f-4671-3976-b60a-09064f1811e8" 7 | session-private-key = "9b727c4954c0f29d9e76258a97f45c4c32a748c3536bee03e486a17d7ba59409" 8 | server-public-key = "849bd198be846981839a5e5bef929cf8b71543ec31d5ff3cee4f272656a921d5" 9 | spend-private-key = "6004d10dab1c2ee8fb512399eeb9aa8ce2112eee07c20df780fb76d840cbcd0e" 10 | 11 | [genesis] 12 | members = [ 13 | "094ac88f-4671-3976-b60a-09064f1811e8", 14 | "094ac88f-4671-3976-b60a-09064f1811e9", 15 | "094ac88f-4671-3976-b60a-09064f1811ea", 16 | "094ac88f-4671-3976-b60a-09064f1811eb", 17 | "094ac88f-4671-3976-b60a-09064f1811ec", 18 | ] 19 | threshold = 3 20 | epoch = 4655227 21 | -------------------------------------------------------------------------------- /mtg/group.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "fmt" 7 | "slices" 8 | "sort" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/MixinNetwork/mixin/common" 14 | "github.com/MixinNetwork/mixin/crypto" 15 | "github.com/MixinNetwork/mixin/logger" 16 | "github.com/fox-one/mixin-sdk-go/v2" 17 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 18 | "github.com/shopspring/decimal" 19 | ) 20 | 21 | const ( 22 | groupGenesisId = "group-genesis-id" 23 | groupBootSynced = "group-boot-synced" 24 | defaultKernelRPC = "https://kernel.mixin.dev" 25 | ) 26 | 27 | type Worker interface { 28 | // process the action in a queue and return transactions 29 | // need to ensure enough balance with CheckAssetBalanceAt(ctx, a) 30 | // before return any transactions, otherwise the transactions 31 | // will be ignored when issuficient balance 32 | // 33 | // if we want to make a multi process worker, it's possible that 34 | // we pass some RPC handle to the process, or we could build a 35 | // whole state of the current sequence and send it to the process 36 | // i.e. ProcessOutput(StateAtSequence, Action) []*Transaction 37 | ProcessOutput(context.Context, *Action) ([]*Transaction, string) 38 | } 39 | 40 | type Group struct { 41 | mixin *mixin.Client 42 | store *SQLite3Store 43 | workers map[string]Worker 44 | entries map[string]string 45 | groupSize int 46 | waitDuration time.Duration 47 | 48 | id string 49 | GroupId string 50 | rawMembers []string 51 | threshold int 52 | index int 53 | epoch uint64 54 | spendPrivateKey string 55 | debug bool 56 | kernelRPC string 57 | } 58 | 59 | func BuildGroup(ctx context.Context, store *SQLite3Store, conf *Configuration) (*Group, error) { 60 | if cg := conf.Genesis; len(cg.Members) < cg.Threshold || cg.Threshold < 1 { 61 | return nil, fmt.Errorf("invalid group threshold %d %d", len(cg.Members), cg.Threshold) 62 | } 63 | if !strings.Contains(strings.Join(conf.Genesis.Members, ","), conf.App.AppId) { 64 | return nil, fmt.Errorf("app %s not belongs to the group", conf.App.AppId) 65 | } 66 | 67 | client, err := mixin.NewFromKeystore(&mixin.Keystore{ 68 | AppID: conf.App.AppId, 69 | SessionID: conf.App.SessionId, 70 | SessionPrivateKey: conf.App.SessionPrivateKey, 71 | ServerPublicKey: conf.App.ServerPublicKey, 72 | }) 73 | if err != nil { 74 | return nil, err 75 | } 76 | if !CheckTestEnvironment(ctx) { 77 | _, err := client.UserMe(ctx) 78 | if err != nil { 79 | return nil, err 80 | } 81 | } 82 | 83 | id := generateGenesisId(conf) 84 | grp := &Group{ 85 | mixin: client, 86 | store: store, 87 | spendPrivateKey: conf.App.SpendPrivateKey, 88 | id: id, 89 | GroupId: UniqueId(id, conf.Project), 90 | groupSize: conf.GroupSize, 91 | waitDuration: time.Duration(conf.LoopWaitDuration), 92 | workers: make(map[string]Worker), 93 | entries: make(map[string]string), 94 | kernelRPC: defaultKernelRPC, 95 | index: -1, 96 | } 97 | if grp.groupSize <= 0 { 98 | grp.groupSize = OutputsBatchSize 99 | } 100 | 101 | oid, err := store.ReadProperty(ctx, groupGenesisId) 102 | if err != nil { 103 | return nil, err 104 | } 105 | if len(oid) > 0 && string(oid) != grp.id { 106 | return nil, fmt.Errorf("malformed group genesis id %s %s", string(oid), grp.id) 107 | } 108 | err = store.WriteProperty(ctx, groupGenesisId, grp.id) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | err = store.WriteProperty(ctx, groupBootSynced, "0") 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | for _, id := range conf.Genesis.Members { 119 | err = grp.AddNode(ctx, id, conf.Genesis.Threshold, conf.Genesis.Epoch) 120 | if err != nil { 121 | return nil, err 122 | } 123 | } 124 | members, threshold, epoch, err := grp.ListActiveNodes(ctx) 125 | if err != nil { 126 | return nil, err 127 | } 128 | sort.Strings(members) 129 | 130 | grp.rawMembers = members 131 | grp.threshold = threshold 132 | grp.index = grp.calculateIndex() 133 | grp.epoch = epoch 134 | return grp, nil 135 | } 136 | 137 | func (grp *Group) GenesisId() string { 138 | return grp.id 139 | } 140 | 141 | func (grp *Group) GetMembers() []string { 142 | ms := make([]string, len(grp.rawMembers)) 143 | n := copy(ms, grp.rawMembers) 144 | if len(grp.rawMembers) != n { 145 | panic(n) 146 | } 147 | if grp.debug { 148 | sort.Strings(ms) 149 | if !slices.Equal(ms, grp.rawMembers) { 150 | panic(ms) 151 | } 152 | } 153 | return ms 154 | } 155 | 156 | func (grp *Group) GetThreshold() int { 157 | return grp.threshold 158 | } 159 | 160 | func (grp *Group) Index() int { 161 | if grp.index < 0 { 162 | panic(grp.index) 163 | } 164 | return grp.index 165 | } 166 | 167 | func (grp *Group) EnableDebug() { 168 | grp.debug = true 169 | } 170 | 171 | func (grp *Group) SetKernelRPC(rpc string) { 172 | grp.kernelRPC = rpc 173 | } 174 | 175 | func (grp *Group) Synced(ctx context.Context) bool { 176 | v, err := grp.store.ReadProperty(ctx, groupBootSynced) 177 | if err != nil { 178 | panic(err) 179 | } 180 | return v == "1" 181 | } 182 | 183 | func (grp *Group) AttachWorker(appId string, wkr Worker) { 184 | if grp.FindWorker(appId) != nil { 185 | panic(appId) 186 | } 187 | grp.workers[appId] = wkr 188 | } 189 | 190 | func (grp *Group) RegisterDepositEntry(appId string, entry DepositEntry) { 191 | key := entry.UniqueKey() 192 | if grp.FindWorker(appId) == nil || grp.FindAppByEntry(key) != "" { 193 | panic(appId) 194 | } 195 | grp.entries[key] = appId 196 | } 197 | 198 | func (grp *Group) FindWorker(appId string) Worker { 199 | return grp.workers[appId] 200 | } 201 | 202 | func (grp *Group) FindAppByEntry(entry string) string { 203 | return grp.entries[entry] 204 | } 205 | 206 | func (grp *Group) calculateIndex() int { 207 | for i, id := range grp.GetMembers() { 208 | if grp.mixin.ClientID == id { 209 | return i 210 | } 211 | } 212 | panic(grp.mixin.ClientID) 213 | } 214 | 215 | func (grp *Group) Run(ctx context.Context) { 216 | logger.Printf("Group(%s, %d, %s).Run(v0.10.1)\n", mixinnet.HashMembers(grp.GetMembers()), grp.threshold, grp.GenesisId()) 217 | filter := make(map[string]bool) 218 | for { 219 | time.Sleep(grp.waitDuration) 220 | // drain all the utxos in the order of sequence 221 | logger.Verbosef("Group.Run(drainOutputsFromNetwork) created\n") 222 | grp.drainOutputsFromNetwork(ctx, filter, 500) 223 | err := grp.store.WriteProperty(ctx, groupBootSynced, "1") 224 | if err != nil { 225 | panic(err) 226 | } 227 | 228 | // handle the utxos queue by sequence 229 | logger.Verbosef("Group.Run(handleActionsQueue)\n") 230 | err = grp.handleActionsQueue(ctx) 231 | if err != nil { 232 | panic(err) 233 | } 234 | 235 | // sign any possible transactions from BuildTransaction 236 | logger.Verbosef("Group.Run(signTransactions)\n") 237 | err = grp.signTransactions(ctx) 238 | if err != nil { 239 | panic(err) 240 | } 241 | 242 | // verify all transactions 243 | logger.Verbosef("Group.Run(publishTransactions)\n") 244 | err = grp.publishTransactions(ctx) 245 | if err != nil { 246 | panic(err) 247 | } 248 | 249 | // verify all withdrawal transactions 250 | logger.Verbosef("Group.Run(confirmWithdrawalTransactions)\n") 251 | err = grp.confirmWithdrawalTransactions(ctx) 252 | if err != nil { 253 | panic(err) 254 | } 255 | } 256 | } 257 | 258 | func (grp *Group) ListOutputsForAsset(ctx context.Context, appId, assetId string, consumedUntil, sequence uint64, state SafeUtxoState, limit int) []*UnifiedOutput { 259 | outputs, err := grp.store.ListOutputsForAsset(ctx, appId, assetId, consumedUntil, sequence, state, limit) 260 | if err != nil { 261 | panic(err) 262 | } 263 | return outputs 264 | } 265 | 266 | func (grp *Group) ListOutputsForTransaction(ctx context.Context, traceId string, sequence uint64) []*UnifiedOutput { 267 | outputs, err := grp.store.ListOutputsForTransaction(ctx, traceId, sequence) 268 | if err != nil { 269 | panic(err) 270 | } 271 | return outputs 272 | } 273 | 274 | func (grp *Group) ListOutputsByTransactionHash(ctx context.Context, hash string, sequence uint64) []*UnifiedOutput { 275 | outputs, err := grp.store.ListOutputsByTransactionHash(ctx, hash, sequence) 276 | if err != nil { 277 | panic(err) 278 | } 279 | return outputs 280 | } 281 | 282 | func (grp *Group) ListUnconfirmedWithdrawalTransactions(ctx context.Context, limit int) []*Transaction { 283 | txs, err := grp.store.ListUnconfirmedWithdrawalTransactions(ctx, limit) 284 | if err != nil { 285 | panic(err) 286 | } 287 | return txs 288 | } 289 | 290 | func (grp *Group) ListConfirmedWithdrawalTransactionsAfter(ctx context.Context, offset time.Time, limit int) []*Transaction { 291 | txs, err := grp.store.ListConfirmedWithdrawalTransactionsAfter(ctx, offset, limit) 292 | if err != nil { 293 | panic(err) 294 | } 295 | return txs 296 | } 297 | 298 | // this function or rpc should be used only in ProcessOutput 299 | func (act *Action) CheckAssetBalanceAt(ctx context.Context, assetId string) decimal.Decimal { 300 | os, err := act.group.store.ListOutputsForAsset(ctx, act.AppId, assetId, act.consumed[assetId], act.Sequence, SafeUtxoStateUnspent, OutputsBatchSize) 301 | if err != nil { 302 | panic(err) 303 | } 304 | 305 | total := decimal.NewFromInt(0) 306 | for _, o := range os { 307 | total = total.Add(o.Amount) 308 | } 309 | return total 310 | } 311 | 312 | func (act *Action) CheckAssetBalanceForStorageAt(ctx context.Context, extra []byte) bool { 313 | if len(extra) > common.ExtraSizeStorageCapacity { 314 | panic(fmt.Errorf("too large extra %d > %d", len(extra), common.ExtraSizeStorageCapacity)) 315 | } 316 | 317 | amount := getStorageTransactionAmount(extra) 318 | total := act.CheckAssetBalanceAt(ctx, StorageAssetId) 319 | return common.NewIntegerFromString(total.String()).Cmp(amount) > 0 320 | } 321 | 322 | func (grp *Group) signTransactionWithAsset(ctx context.Context, wg *sync.WaitGroup, asset string, txs []*Transaction) { 323 | logger.Verbosef("Group.signTransactionWithAsset(%s)", asset) 324 | defer wg.Done() 325 | 326 | for _, tx := range txs { 327 | ver := grp.signTransaction(ctx, tx) 328 | if ver == nil { 329 | break 330 | } 331 | logger.Verbosef("Group.signTransaction(%v) => %s", *tx, hex.EncodeToString(ver.Marshal())) 332 | } 333 | } 334 | 335 | func (grp *Group) signTransactions(ctx context.Context) error { 336 | _, assetTxMap, err := grp.store.ListTransactions(ctx, TransactionStateInitial, 0) 337 | if err != nil { 338 | panic(err) 339 | } 340 | 341 | var wg sync.WaitGroup 342 | for asset, txs := range assetTxMap { 343 | wg.Add(1) 344 | go grp.signTransactionWithAsset(ctx, &wg, asset, txs) 345 | } 346 | wg.Wait() 347 | return nil 348 | } 349 | 350 | func (grp *Group) publishTransactions(ctx context.Context) error { 351 | txs, _, err := grp.store.ListTransactions(ctx, TransactionStateSigned, 0) 352 | if err != nil || len(txs) == 0 { 353 | return err 354 | } 355 | for _, tx := range txs { 356 | snapshot, err := grp.snapshotTransaction(ctx, tx) 357 | if err != nil { 358 | return err 359 | } else if !snapshot { 360 | continue 361 | } 362 | err = grp.store.FinishTransaction(ctx, tx.TraceId) 363 | if err != nil { 364 | return err 365 | } 366 | } 367 | return nil 368 | } 369 | 370 | func (grp *Group) snapshotTransaction(ctx context.Context, tx *Transaction) (bool, error) { 371 | req, err := grp.readTransactionUntilSufficient(ctx, tx.RequestID()) 372 | logger.Verbosef("group.readTransactionUntilSufficient(%s, %s) => %v", tx.TraceId, tx.RequestID(), err) 373 | if err != nil || req == nil { 374 | return false, err 375 | } 376 | if req.TransactionHash != tx.Hash.String() { 377 | panic(tx.TraceId) 378 | } 379 | return req.State == SafeUtxoStateSpent, nil 380 | } 381 | 382 | func (grp *Group) confirmWithdrawalTransactions(ctx context.Context) error { 383 | txs, err := grp.store.ListUnconfirmedWithdrawalTransactions(ctx, 100) 384 | if err != nil || len(txs) == 0 { 385 | return err 386 | } 387 | for _, tx := range txs { 388 | req, err := grp.readTransactionUntilSufficient(ctx, tx.RequestID()) 389 | logger.Verbosef("group.readTransactionUntilSufficient(%s, %s) => %v", tx.TraceId, tx.RequestID(), err) 390 | if err != nil { 391 | return err 392 | } 393 | if req.TransactionHash != tx.Hash.String() || req.Receivers[0].Destination != tx.Destination.String { 394 | panic(tx.TraceId) 395 | } 396 | if req.Receivers[0].WithdrawalHash == "" { 397 | continue 398 | } 399 | err = grp.store.ConfirmWithdrawalTransaction(ctx, tx.TraceId, req.Receivers[0].WithdrawalHash) 400 | if err != nil { 401 | return err 402 | } 403 | } 404 | return nil 405 | } 406 | 407 | func generateGenesisId(conf *Configuration) string { 408 | sort.Slice(conf.Genesis.Members, func(i, j int) bool { 409 | return conf.Genesis.Members[i] < conf.Genesis.Members[j] 410 | }) 411 | id := strings.Join(conf.Genesis.Members, "") 412 | id = fmt.Sprintf("%s:%d:%d", id, conf.Genesis.Threshold, conf.Genesis.Epoch) 413 | return crypto.Sha256Hash([]byte(id)).String() 414 | } 415 | -------------------------------------------------------------------------------- /mtg/hack.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | // the old mtg delays the transaction build, then if a single action has multiple 4 | // transactions, the service may choose utxos in a different order from the 5 | // ProcessOutput return value 6 | var safeTransactionSequenceOrderHack = map[string][]string{ 7 | // 16452583 8 | "640ae809-4c32-3e6a-8ffc-0e45a5086ac5": {"a3cc6067-3461-3fa4-a606-017de4e38aa0"}, 9 | "baef789c-18ed-3ea8-a55e-a6dde9f638d1": {"eed4d045-6f8e-320c-be85-9082f4bf35e7"}, 10 | // 16534005 11 | "181d5330-5cb3-33f2-bb1c-6b0f93a2adf0": {"f3fa77a3-b5d7-349d-94d9-7bda187ceee0"}, 12 | "44130753-26f8-369d-8700-f076db1ad5e5": {"f84012a7-83f0-3321-b60e-889bdfc1a129"}, 13 | "74fd2b7e-ac11-3f3d-865b-315d816c95c7": {"cfd87b97-1547-3995-990d-1b3f988aaa73"}, 14 | "9f91c79b-3f5e-37c2-a94a-4fab0226e601": {"bdb537b8-7799-3960-9e4a-88202455956b"}, 15 | "da84ca18-04a5-37ed-b04b-059ff3d51726": {"405a797b-1277-33a1-9cdf-508f38f552e8"}, 16 | // 16547515 17 | "2c53689a-1315-31fb-9c0e-444be15d4402": {"f39fc15c-0ab3-302f-a7a3-873d491faea2"}, 18 | "5c3faac6-d318-382d-a1d3-94bcb0c4b9d2": {"12590751-bac5-34f3-87bc-c4ff84c3adda"}, 19 | "b7621abc-8081-3230-a464-ac00be5003b2": {"28de7242-9753-3b89-934f-5cd522d772f3"}, 20 | "f9cf4291-3b2f-395b-b90d-dfcf045b813f": {"dbebcf5b-ea64-31e1-b354-10ef425bfa65"}, 21 | // 17204534 22 | "040267ad-020d-3590-9930-19e5e85c6d18": {"700bda94-45a9-39ed-907d-e7b319dc6122"}, 23 | "07987178-2a25-37a3-9688-e41513be63a5": {"3b16f680-2aa6-3c1e-9dca-c1a4af0c53e0"}, 24 | "1de1084b-9363-3864-8570-f9c7d1771527": {"f4d32a51-e352-340e-a471-fd4f3cbe0a3e"}, 25 | "23265a98-e2a8-3b4c-8e12-d222b26d22d5": {"6b2d079e-86de-3bbb-bcd5-bcd1019b9a23"}, 26 | "24b5e3b6-5799-3d09-a1c7-d8c55dd3bbc8": {"b9757f62-7827-3111-aa81-9e5220905e3c"}, 27 | "350afbc1-5b79-3388-b45d-b1d4656de7cd": {"df902f91-374b-346c-bac9-162f27344b4e"}, 28 | "59c5263b-a3a1-3c00-9eff-0c44fdcca2f1": {"63b8f572-88c8-3369-9c00-7907d13bb3a8"}, 29 | "5e65b4c6-9876-362f-b7b7-b0862e2706c3": {"25158f15-44c0-3379-a6a6-9bafef06ce69"}, 30 | "83b606d9-c702-3065-a531-f0705e97ff96": {"664f13bf-2473-3a86-bbfe-d71d57283e44"}, 31 | "84a376db-5c38-34b1-a5f8-e9ea96105466": {"f049eb3b-ba11-334f-8b10-11ebbd59c92e"}, 32 | "9e5c7640-9732-3b1b-b546-cb68f3b674e0": {"b0f8c77d-2c78-3582-8a25-e1038fc39a37"}, 33 | "a0e295af-dc07-39a1-807e-599f4cd50d92": {"23c20873-7e1f-3171-95d0-3f5db7d86057"}, 34 | "a614acb5-d233-3d75-81a8-69e214502eb7": {"f1bd1b03-b8b5-3eaf-a46f-588b911fec3d"}, 35 | "afa18963-19c7-315c-aa08-c288d770b192": {"9a5def01-1360-3965-8cda-fda1e93b15cb"}, 36 | "c3dfb8b1-02a1-318c-a011-8db8f82a6615": {"6c55b030-7591-3065-9645-6c5bf2fd7df4"}, 37 | "e634ab84-4d0b-3490-833b-89596278be99": {"1c7d0e84-6e43-3d6b-8b85-8a2d708ed68f"}, 38 | "e6f8cabb-8178-3e86-9f0a-f6391d63c4e8": {"b83927ed-9c6a-3b0d-b2e9-b44851762ac1"}, 39 | "f4d07109-6b81-3a1b-ace6-4562ec458cf8": {"13c3311d-ef68-31b8-b751-3ab8bc5b791a"}, 40 | // 17207593 41 | "22acd598-e155-3992-842f-0422a19705be": {"a082f0d2-190c-3038-927f-8cda9d052060"}, 42 | "410fafdd-2580-3d19-840a-8d9b0923d600": {"5beb54de-572c-319a-8cf0-36072218abef"}, 43 | "52a08161-ac8e-3b77-aa19-11f98e4cd81e": {"6c0557ff-5a3b-3194-94fc-b3b87e692300"}, 44 | "6c356496-25e9-3029-b3be-6a9afb8dd61b": {"ba46fead-13a0-3f09-9351-b205bccf3c1f"}, 45 | "79d33c40-0a42-3841-90d0-1884ee3d472e": {"0f5e3844-fc12-338d-8448-2bf51d248e14"}, 46 | "9a525e52-843b-3666-b62c-4bc5c2b960ae": {"45c90745-f228-3527-bbdc-00e51f412e0b"}, 47 | "a79575e2-75aa-3f7f-a5ed-e62903fc1141": {"05b7d1a0-ff2e-3561-8f9e-8c3adc8552b2"}, 48 | "b528a816-ead3-3c99-a8a5-b09ebab65ba1": {"ffa16fdb-074d-34bd-bea0-74dfad39ab7b"}, 49 | "cd1428b6-8585-3897-84da-c53c3919b61f": {"0a001297-3398-3a49-9b03-0e623f0b0f41"}, 50 | "d02fc51f-5d27-30eb-9539-346bef57f98e": {"c0a6db02-a3b6-3e39-82b9-6de9285eda6a"}, 51 | "eb3e3207-0ace-3b19-b25e-32a6f6899817": {"a30eae96-f685-3e28-ba39-228a3fbf871d"}, 52 | // 17209233 53 | "22feee5d-35f2-3305-b62d-99d37e78a138": {"cb83a954-b89d-3717-b164-336b42a081f2"}, 54 | "79c3eaa5-f52a-324d-8a3a-29c1122c159b": {"c6eca04c-676b-3be9-9bde-c27db65d4baf"}, 55 | "7e179f33-8fb3-31be-adff-d2fb97f29309": {"f9f7dc25-ebfe-3cb5-8ce2-48d2fb51b071"}, 56 | "8b1b9b80-57ff-36f6-a45b-8a9b82e78250": {"4671f73a-f69a-31a7-a1e0-97cf6af8e24d"}, 57 | "a32a1aa1-ccfe-36f5-bc5b-1535b54d4efa": {"9224333e-b4cd-30b7-ab82-85b768f22a07"}, 58 | "b2eab590-2376-387d-bf26-03839a3e8830": {"03251209-0a94-3cda-9452-f8372c70bde2"}, 59 | "b755de3f-9015-355f-93ab-8b592eb335d9": {"2152d01b-c643-3cab-9698-942212bfb7b1"}, 60 | "d0c2f714-92cc-3d05-9868-69db237a03cd": {"14022f90-ea2a-34e8-9ba7-f205b17fcece"}, 61 | "d4fc3cc9-1ab9-361c-aded-33c22252711f": {"029c3cf8-1a7e-3cc8-a7c0-d3265121f94e"}, 62 | "edfe4a02-546a-3a36-b9b6-5b34dedfc712": {"bbc954b2-fda8-3812-84e3-ad492b2e810c"}, 63 | // 17210516 64 | "1d21cc78-1e69-30c3-a4fc-ff79cf0e2a7e": {"97a47de9-7f4f-3663-aea2-f5ae899e936c"}, 65 | "289291e8-eb1c-3628-b7c4-c56777e49f60": {"37ef5b07-c834-3fe7-846d-211129c14830"}, 66 | "31dc26aa-e5e5-3726-8007-fd06daf290cb": {"7a877d0d-2093-37b0-bc39-90e5be98d1fe"}, 67 | "39897fbb-d71d-3346-a1be-05295d3e1314": {"435d91cc-e473-33fc-a908-dcff15b9ade7"}, 68 | "59f3d9fd-6fc1-32d4-aebd-58b1b62e5090": {"3bbf8c8b-61c4-384e-8c08-924eb416b5c4"}, 69 | "677c565b-f821-35e5-9afc-005ba813d0ed": {"2478e62c-0450-338a-a5ab-6be52c025e04"}, 70 | "a904dd25-7a6b-3c7f-b374-48ba07a98190": {"2bcac65c-e38d-32cc-a805-59c1af458a38"}, 71 | "b1394dd3-4bc2-3eff-b4fb-87c849a04ba4": {"c8603ad5-4cf2-3dc7-887c-aa9b1210160c"}, 72 | "e068b8c0-c3a2-3a8a-9377-775891334a3a": {"0c9f8a96-6625-3a6c-8ecd-672c81489ded"}, 73 | "fe9808b8-e9f4-3786-ba8d-9a5f3042d451": {"96b114f2-abb3-3346-86f7-f54f8cfbd558"}, 74 | } 75 | -------------------------------------------------------------------------------- /mtg/iteration.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | const ( 9 | IterationActionAdd = 11 10 | IterationActionRemove = 12 11 | ) 12 | 13 | // a node joins or leaves the group with an iteration 14 | // this is for the evolution mechanism of MTG 15 | // TODO not implemented yet 16 | type Iteration struct { 17 | Action int 18 | NodeId string 19 | Threshold int 20 | CreatedAt uint64 21 | } 22 | 23 | var iterationCols = []string{"action", "node_id", "threshold", "created_at"} 24 | 25 | func (i *Iteration) values() []any { 26 | return []any{i.Action, i.NodeId, i.Threshold, i.CreatedAt} 27 | } 28 | 29 | func iterationFromRow(row Row) (*Iteration, error) { 30 | var i Iteration 31 | err := row.Scan(&i.Action, &i.NodeId, &i.Threshold, &i.CreatedAt) 32 | if err == sql.ErrNoRows { 33 | return nil, nil 34 | } 35 | return &i, err 36 | } 37 | 38 | func (grp *Group) AddNode(ctx context.Context, id string, threshold int, epoch uint64) error { 39 | ir := &Iteration{ 40 | Action: IterationActionAdd, 41 | NodeId: id, 42 | Threshold: threshold, 43 | CreatedAt: epoch, 44 | } 45 | return grp.store.WriteIteration(ctx, ir) 46 | } 47 | 48 | func (grp *Group) ListActiveNodes(ctx context.Context) ([]string, int, uint64, error) { 49 | irs, err := grp.store.ListIterations(ctx) 50 | var actives []string 51 | for _, ir := range irs { 52 | if ir.Action == IterationActionAdd { 53 | actives = append(actives, ir.NodeId) 54 | } 55 | } 56 | if err != nil || len(actives) == 0 { 57 | return nil, 0, 0, err 58 | } 59 | last := irs[len(irs)-1] 60 | return actives, last.Threshold, last.CreatedAt, nil 61 | } 62 | -------------------------------------------------------------------------------- /mtg/mtg_test.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "fmt" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/MixinNetwork/mixin/crypto" 13 | "github.com/MixinNetwork/mixin/logger" 14 | "github.com/fox-one/mixin-sdk-go/v2" 15 | "github.com/gofrs/uuid/v5" 16 | "github.com/pelletier/go-toml" 17 | "github.com/shopspring/decimal" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | const ( 22 | USDTAssetId = "218bc6f4-7927-3f8e-8568-3a3725b74361" 23 | SOLAssetId = "64692c23-8971-4cf4-84a7-4dd1271dd887" 24 | testSender = "e14c1573-3aca-48b1-b437-766b4757b50d" 25 | 26 | testWithdrawalDestination = "73yoz7kK3zgh2ScD9aTJpXCrKHETi1xyEKfMTH95ugff" 27 | testWithdrawalAmount = "0.0049" 28 | testWithdrawalMemo = "withdrawal-test" 29 | ) 30 | 31 | type Node struct { 32 | Group *Group 33 | } 34 | 35 | var actionResult map[string]string 36 | 37 | func (n *Node) ProcessOutput(ctx context.Context, a *Action) ([]*Transaction, string) { 38 | if actionResult[a.OutputId] != "" { 39 | data, err := hex.DecodeString(actionResult[a.OutputId]) 40 | if err != nil { 41 | panic(err) 42 | } 43 | txs, err := DeserializeTransactions(data) 44 | if err != nil { 45 | panic(err) 46 | } 47 | return txs, "" 48 | } 49 | 50 | b, err := hex.DecodeString(a.Extra) 51 | if err != nil { 52 | panic(err) 53 | } 54 | b, err = base64.RawStdEncoding.DecodeString(string(b)) 55 | if err != nil { 56 | return []*Transaction{}, "" 57 | } 58 | memo := string(b) 59 | items := SplitIds(memo) 60 | 61 | var txs []*Transaction 62 | var storageTraceId string 63 | for _, tx := range items { 64 | switch tx { 65 | case "storage": 66 | extra := []byte("storage-memo") 67 | enough := a.CheckAssetBalanceForStorageAt(ctx, extra) 68 | if !enough { 69 | panic(a.Sequence) 70 | } 71 | t := a.BuildStorageTransaction(ctx, extra) 72 | storageTraceId = t.TraceId 73 | txs = append(txs, t) 74 | case "withdrawal": 75 | tid := "cf0564ba-bf51-4e8c-b504-3beb6c5c65e3" 76 | t := a.BuildWithdrawTransaction(ctx, tid, SOLAssetId, testWithdrawalAmount, testWithdrawalMemo, testWithdrawalDestination, "") 77 | txs = append(txs, t) 78 | default: 79 | amt := decimal.RequireFromString(tx) 80 | balance := a.CheckAssetBalanceAt(ctx, a.AssetId) 81 | if balance.Cmp(amt) < 0 { 82 | return nil, USDTAssetId 83 | } 84 | 85 | amount := amt.String() 86 | id := UniqueId(amount, testSender) 87 | var t *Transaction 88 | if storageTraceId != "" { 89 | t = a.BuildTransactionWithStorageTraceId(ctx, id, UniqueId(a.AppId, "opponent"), a.AssetId, amount, "", n.Group.GetMembers(), n.Group.GetThreshold(), storageTraceId) 90 | } else { 91 | t = a.BuildTransaction(ctx, id, UniqueId(a.AppId, "opponent"), a.AssetId, amount, "", n.Group.GetMembers(), n.Group.GetThreshold()) 92 | } 93 | txs = append(txs, t) 94 | } 95 | } 96 | 97 | if actionResult[a.OutputId] == "" { 98 | data := SerializeTransactions(txs) 99 | actionResult[a.OutputId] = hex.EncodeToString(data) 100 | } 101 | 102 | return txs, "" 103 | } 104 | 105 | func TestMTGExtra(t *testing.T) { 106 | require := require.New(t) 107 | id := uuid.Must(uuid.NewV4()).String() 108 | memo := "123" 109 | 110 | extra := EncodeMixinExtraBase64(id, []byte(memo)) 111 | a, m := DecodeMixinExtraHEX(hex.EncodeToString([]byte(extra))) 112 | require.Equal(id, a) 113 | require.Equal(memo, string(m)) 114 | } 115 | 116 | func TestMTGCompaction(t *testing.T) { 117 | require := require.New(t) 118 | ctx, node := testBuildGroup(require) 119 | require.NotNil(node) 120 | defer teardownTestDatabase(node.Group.store) 121 | 122 | testDrainInitialOutputs(ctx, require, node.Group, "0.0037") 123 | 124 | as, err := node.Group.store.ListActions(ctx, ActionStateDone, 0) 125 | require.Nil(err) 126 | require.Len(as, OutputsBatchSize+1) 127 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 128 | require.Nil(err) 129 | require.Len(as, 1) 130 | hash := as[0].TransactionHash 131 | 132 | wkr := node.Group.FindWorker(as[0].AppId) 133 | require.NotNil(wkr) 134 | err = node.Group.handleActionsQueue(ctx) 135 | require.Nil(err) 136 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 137 | require.Nil(err) 138 | require.Len(as, 0) 139 | as, err = node.Group.store.ListActions(ctx, ActionStateRestorable, 0) 140 | require.Nil(err) 141 | require.Len(as, 1) 142 | 143 | out := testHandleCompactionTransaction(ctx, require, node.Group, hash) 144 | node.Group.processSafeOutput(ctx, out) 145 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 146 | require.Nil(err) 147 | require.Len(as, 1) 148 | err = node.Group.handleActionsQueue(ctx) 149 | require.Nil(err) 150 | 151 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 152 | require.Nil(err) 153 | require.Len(as, 1) 154 | 155 | as, err = node.Group.store.ListActions(ctx, ActionStateRestorable, 0) 156 | require.Nil(err) 157 | require.Len(as, 0) 158 | err = node.Group.handleActionsQueue(ctx) 159 | require.Nil(err) 160 | ts, _, err := node.Group.store.ListTransactions(ctx, TransactionStateInitial, 0) 161 | require.Nil(err) 162 | require.Len(ts, 1) 163 | 164 | tx := ts[0] 165 | tx.consumed = node.Group.ListOutputsForTransaction(ctx, tx.TraceId, tx.Sequence) 166 | for _, o := range tx.consumed { 167 | tx.consumedIds = append(tx.consumedIds, o.OutputId) 168 | } 169 | tsb := SerializeTransactions(ts) 170 | require.Equal("010154b26ff29615c93faf99f74c4c5dcdb8847201c7d7eac8374ca5ec9dcb47a38fa5e559f9bda186359d9a1aba83cc3c0fa94c7337f67eaa3fcb95ee8513140604a20a218bc6f479273f8e85683a3725b743610006302e303033370000000000000047090500000000000000000000000000000000000000024c7337f67eaa3fcb95ee8513140604a28194ae75a00338f8ab2b4ce9ffbc87f4000000b862313237626363352d353536382d333832622d383937612d3531613131356133623734612c33306139393264622d666133362d333638302d396436392d3065363939333662373837622c38333864333032642d613131392d336462382d393966352d3034386533323235653437312c32613430363437612d376337642d333331392d616464332d6366393566346237383338642c31343662323264332d393766382d333938662d383036622d39393565633134393764373503", hex.EncodeToString(tsb)) 171 | dts, err := DeserializeTransactions(tsb) 172 | require.Nil(err) 173 | require.Len(dts, 1) 174 | require.True(ts[0].Equal(dts[0])) 175 | } 176 | 177 | func TestMTGCheckTxs(t *testing.T) { 178 | require := require.New(t) 179 | ctx, node := testBuildGroup(require) 180 | require.NotNil(node) 181 | defer teardownTestDatabase(node.Group.store) 182 | 183 | testDrainInitialOutputs(ctx, require, node.Group, "0.003,0.0008") 184 | 185 | as, err := node.Group.store.ListActions(ctx, ActionStateDone, 0) 186 | require.Nil(err) 187 | require.Len(as, OutputsBatchSize+1) 188 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 189 | require.Nil(err) 190 | require.Len(as, 1) 191 | 192 | wkr := node.Group.FindWorker(as[0].AppId) 193 | require.NotNil(wkr) 194 | err = node.Group.handleActionsQueue(ctx) 195 | require.NotNil(err) 196 | require.True(strings.Contains(err.Error(), "insufficient outputs")) 197 | } 198 | 199 | func TestMTGStorage(t *testing.T) { 200 | require := require.New(t) 201 | ctx, node := testBuildGroup(require) 202 | require.NotNil(node) 203 | defer teardownTestDatabase(node.Group.store) 204 | 205 | testDrainInitialOutputs(ctx, require, node.Group, "storage,0.0001") 206 | 207 | as, err := node.Group.store.ListActions(ctx, ActionStateDone, 0) 208 | require.Nil(err) 209 | require.Len(as, OutputsBatchSize+1) 210 | as, err = node.Group.store.ListActions(ctx, ActionStateInitial, 0) 211 | require.Nil(err) 212 | require.Len(as, 1) 213 | 214 | wkr := node.Group.FindWorker(as[0].AppId) 215 | require.NotNil(wkr) 216 | err = node.Group.handleActionsQueue(ctx) 217 | require.Nil(err) 218 | 219 | txs, _, err := node.Group.store.ListTransactions(ctx, TransactionStateInitial, 0) 220 | require.Nil(err) 221 | require.Len(txs, 2) 222 | require.True(txs[1].storage) 223 | require.Equal(txs[1].TraceId, txs[0].storageTraceId) 224 | 225 | ver := node.Group.signTransaction(ctx, txs[0]) 226 | require.Nil(ver) 227 | tx, err := node.Group.store.ReadTransactionByTraceId(ctx, txs[0].TraceId) 228 | require.Nil(err) 229 | require.Equal(TransactionStateInitial, tx.State) 230 | 231 | ver = node.Group.signTransaction(ctx, txs[1]) 232 | require.NotNil(ver) 233 | err = node.Group.store.FinishTransaction(ctx, txs[1].TraceId) 234 | require.Nil(err) 235 | ver = node.Group.signTransaction(ctx, txs[0]) 236 | require.NotNil(ver) 237 | 238 | storage, err := node.Group.store.ReadTransactionByTraceId(ctx, txs[1].TraceId) 239 | require.Nil(err) 240 | tx, err = node.Group.store.ReadTransactionByTraceId(ctx, txs[0].TraceId) 241 | require.Nil(err) 242 | require.NotEqual(TransactionStateInitial, tx.State) 243 | require.Equal(storage.Hash.String(), tx.references[0].String()) 244 | } 245 | 246 | func TestMTGWithdrawal(t *testing.T) { 247 | require := require.New(t) 248 | ctx, node := testBuildGroup(require) 249 | require.NotNil(node) 250 | defer teardownTestDatabase(node.Group.store) 251 | 252 | d, err := decimal.NewFromString("0.005") 253 | require.Nil(err) 254 | err = node.Group.store.WriteAction(ctx, &UnifiedOutput{ 255 | OutputId: "7514b939-db92-3d31-abf4-7841f035e400", 256 | TransactionRequestId: "cf0564ba-bf51-4e8c-b504-3beb6c5c65e2", 257 | TransactionHash: "01c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee", 258 | OutputIndex: 0, 259 | AssetId: SOLAssetId, 260 | Amount: d, 261 | SendersThreshold: int64(1), 262 | Senders: []string{testSender}, 263 | ReceiversThreshold: int64(node.Group.GetThreshold()), 264 | Extra: "", 265 | State: SafeUtxoStateUnspent, 266 | Sequence: 4655227, 267 | AppId: node.Group.GroupId, 268 | }, ActionStateDone) 269 | require.Nil(err) 270 | 271 | testDrainInitialOutputs(ctx, require, node.Group, "withdrawal") 272 | as, err := node.Group.store.ListActions(ctx, ActionStateInitial, 0) 273 | require.Nil(err) 274 | require.Len(as, 1) 275 | wkr := node.Group.FindWorker(as[0].AppId) 276 | require.NotNil(wkr) 277 | err = node.Group.handleActionsQueue(ctx) 278 | require.Nil(err) 279 | 280 | txs, _, err := node.Group.store.ListTransactions(ctx, TransactionStateInitial, 0) 281 | require.Nil(err) 282 | require.Len(txs, 1) 283 | tx, err := Deserialize(txs[0].Serialize()) 284 | require.Nil(err) 285 | require.Equal(testWithdrawalAmount, tx.Amount) 286 | require.Equal(testWithdrawalMemo, tx.Memo) 287 | require.Equal(SOLAssetId, tx.AssetId) 288 | require.Equal(testWithdrawalDestination, tx.Destination.String) 289 | require.Equal("", tx.Tag.String) 290 | require.False(tx.WithdrawalHash.Valid) 291 | 292 | outputs := node.Group.ListOutputsForTransaction(ctx, tx.TraceId, tx.Sequence) 293 | require.True(len(outputs) > 0) 294 | ver, consumed, err := node.Group.buildRawTransaction(ctx, tx, outputs) 295 | require.Nil(err) 296 | require.True(len(outputs) == len(consumed)) 297 | raw := hex.EncodeToString(ver.Marshal()) 298 | require.Equal( 299 | "77770005481360491383ebd4f0f97543f3440313b48b8fd06dcfa5a0c2cabe4252d3a8eb000101c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee0000000000000000000200a10003077a100000000000000000000000000000000000000000000000000000000000000000000000007777002c3733796f7a376b4b337a6768325363443961544a705843724b48455469317879454b664d544839357567666600000000000227100002f5c8b3dbb7a5b2f7e1e4640d9f61c142cda547917f227ba21ebc5d554651c50d18f71fbe1b5055f3d882a4ae2813fad315bf0dcb5a0e60f091121db882baff77f18e0e276648b1d42063f8bcf9d5a57252f4048c9939ded0999a0e263716976e0003fffe02000000000000000f7769746864726177616c2d746573740000", 300 | raw, 301 | ) 302 | _, err = node.Group.updateTxWithOutputs(ctx, tx, consumed, &mixin.SafeMultisigRequest{ 303 | RequestID: tx.RequestID(), 304 | TransactionHash: "f45e51276a031a46d25998605324e8a3f1b720d33f66dc226018448f53bda4c4", 305 | RawTransaction: raw, 306 | }) 307 | require.Nil(err) 308 | 309 | txs, _, err = node.Group.store.ListTransactions(ctx, TransactionStateInitial, 0) 310 | require.Nil(err) 311 | require.Len(txs, 0) 312 | txs, _, err = node.Group.store.ListTransactions(ctx, TransactionStateSigned, 0) 313 | require.Nil(err) 314 | require.Len(txs, 1) 315 | 316 | err = node.Group.store.FinishTransaction(ctx, tx.TraceId) 317 | require.Nil(err) 318 | txs, _, err = node.Group.store.ListTransactions(ctx, TransactionStateSigned, 0) 319 | require.Nil(err) 320 | require.Len(txs, 0) 321 | txs, _, err = node.Group.store.ListTransactions(ctx, TransactionStateSnapshot, 0) 322 | require.Nil(err) 323 | require.Len(txs, 1) 324 | 325 | tx = txs[0] 326 | tx.consumed = node.Group.ListOutputsForTransaction(ctx, tx.TraceId, tx.Sequence) 327 | for _, o := range tx.consumed { 328 | tx.consumedIds = append(tx.consumedIds, o.OutputId) 329 | } 330 | tsb := SerializeTransactions(txs) 331 | require.Equal("0100c8cf0564babf514e8cb5043beb6c5c65e37201c7d7eac8374ca5ec9dcb47a38fa57201c7d7eac8374ca5ec9dcb47a38fa5276192fd01413e56a50ff04061a218770d64692c2389714cf484a74dd1271dd8870006302e30303439000f7769746864726177616c2d7465737400000000004708a100000000000000000000000000000000000000017514b939db923d31abf47841f035e4007777002c3733796f7a376b4b337a6768325363443961544a705843724b48455469317879454b664d54483935756766660000", hex.EncodeToString(tsb)) 332 | dtxs, err := DeserializeTransactions(tsb) 333 | require.Nil(err) 334 | require.Len(dtxs, 1) 335 | tx.Hash = crypto.Hash{} 336 | tx.Raw = nil 337 | require.True(txs[0].Equal(dtxs[0])) 338 | } 339 | 340 | func testHandleCompactionTransaction(ctx context.Context, require *require.Assertions, group *Group, hash string) *UnifiedOutput { 341 | ts, _, err := group.store.ListTransactions(ctx, TransactionStateInitial, 0) 342 | require.Nil(err) 343 | require.Len(ts, 1) 344 | 345 | tx := ts[0] 346 | require.True(tx.compaction) 347 | outputs := group.ListOutputsForAsset(ctx, tx.AppId, tx.AssetId, 0, tx.Sequence, SafeUtxoStateAssigned, OutputsBatchSize) 348 | require.Len(outputs, 36) 349 | ver, consumed, err := group.buildRawTransaction(ctx, tx, outputs) 350 | require.Nil(err) 351 | require.Len(consumed, 36) 352 | require.Len(ver.References, 1) 353 | require.Equal(ver.References[0].String(), hash) 354 | 355 | tx.Hash = ver.PayloadHash() 356 | tx.Raw = ver.Marshal() 357 | tx.State = TransactionStateSnapshot 358 | for _, out := range consumed { 359 | out.State = SafeUtxoStateSpent 360 | out.SignedBy = tx.Hash.String() 361 | } 362 | err = group.store.UpdateTxWithOutputs(ctx, tx, consumed) 363 | require.Nil(err) 364 | 365 | return testBuildActionFromTx(require, group, tx) 366 | } 367 | 368 | func testBuildActionFromTx(require *require.Assertions, group *Group, tx *Transaction) *UnifiedOutput { 369 | extra := encodeMixinExtra(tx.AppId, []byte(tx.Memo)) 370 | extra = hex.EncodeToString([]byte(extra)) 371 | return testBuildOutput(group, require, tx.AssetId, tx.Amount, extra, SafeUtxoStateUnspent, tx.Sequence+100, tx.Hash.String()) 372 | } 373 | 374 | func testDrainInitialOutputs(ctx context.Context, require *require.Assertions, group *Group, memo string) { 375 | count := OutputsBatchSize + 1 376 | start := 4655228 377 | 378 | out := testBuildOutput(group, require, StorageAssetId, "1", "", SafeUtxoStateUnspent, uint64(start), "") 379 | err := group.store.WriteAction(ctx, out, ActionStateDone) 380 | require.Nil(err) 381 | 382 | for i := range count { 383 | extra := "" 384 | state := ActionStateDone 385 | if i+1 == count { 386 | extra = base64.RawStdEncoding.EncodeToString([]byte(memo)) 387 | extra = hex.EncodeToString([]byte(extra)) 388 | state = ActionStateInitial 389 | } 390 | out := testBuildOutput(group, require, USDTAssetId, "0.0001", extra, SafeUtxoStateUnspent, uint64(start+i+1), "") 391 | 392 | err := group.store.WriteAction(ctx, out, state) 393 | require.Nil(err) 394 | } 395 | } 396 | 397 | func testBuildOutput(group *Group, require *require.Assertions, asset, amount string, extra string, state SafeUtxoState, sequence uint64, hash string) *UnifiedOutput { 398 | oid := UniqueId("output", fmt.Sprintf("%s:%s:%s:%s:%d", amount, extra, extra, state, sequence)) 399 | rid := UniqueId("request", oid) 400 | h := crypto.Sha256Hash(uuid.FromStringOrNil(oid).Bytes()) 401 | if hash != "" { 402 | hash, err := crypto.HashFromString(hash) 403 | require.Nil(err) 404 | h = hash 405 | } 406 | oid = mixin.UniqueConversationID(fmt.Sprintf("%s:%d", h, 0), "") 407 | amt := decimal.RequireFromString(amount) 408 | 409 | return &UnifiedOutput{ 410 | OutputId: oid, 411 | TransactionRequestId: rid, 412 | TransactionHash: h.String(), 413 | OutputIndex: 0, 414 | AssetId: asset, 415 | Amount: amt, 416 | SendersThreshold: int64(1), 417 | Senders: []string{testSender}, 418 | ReceiversThreshold: int64(group.GetThreshold()), 419 | Extra: extra, 420 | State: state, 421 | Sequence: sequence, 422 | AppId: group.GroupId, 423 | } 424 | } 425 | 426 | func testBuildGroup(require *require.Assertions) (context.Context, *Node) { 427 | logger.SetLevel(logger.INFO) 428 | ctx := context.Background() 429 | ctx = EnableTestEnvironment(ctx) 430 | 431 | f, _ := os.ReadFile("./example.toml") 432 | var conf Configuration 433 | err := toml.Unmarshal(f, &conf) 434 | require.Nil(err) 435 | 436 | root, err := os.MkdirTemp("", "mtg-test") 437 | require.Nil(err) 438 | conf.StoreDir = root 439 | if !(strings.HasPrefix(conf.StoreDir, "/tmp") || strings.HasPrefix(conf.StoreDir, "/var/folders")) { 440 | panic(root) 441 | } 442 | store, err := OpenSQLite3Store(conf.StoreDir + "/mtg.sqlite3") 443 | require.Nil(err) 444 | 445 | group, err := BuildGroup(ctx, store, &conf) 446 | require.Nil(err) 447 | group.groupSize = 1 448 | group.EnableDebug() 449 | 450 | n := &Node{ 451 | Group: group, 452 | } 453 | group.AttachWorker(group.GroupId, n) 454 | 455 | d := DepositEntry{ 456 | Destination: "213", 457 | Tag: "", 458 | } 459 | group.RegisterDepositEntry(group.GroupId, d) 460 | 461 | app := group.FindAppByEntry("") 462 | require.Equal("", app) 463 | app = group.FindAppByEntry(d.UniqueKey()) 464 | require.Equal(group.GroupId, app) 465 | 466 | ns, err := group.store.ListIterations(ctx) 467 | require.Nil(err) 468 | require.Len(ns, 5) 469 | 470 | return ctx, n 471 | } 472 | 473 | func teardownTestDatabase(store *SQLite3Store) { 474 | dropTablesDDL := ` 475 | DROP TABLE IF EXISTS properties; 476 | DROP TABLE IF EXISTS iterations; 477 | DROP TABLE IF EXISTS actions; 478 | DROP TABLE IF EXISTS outputs; 479 | DROP TABLE IF EXISTS transactions; 480 | ` 481 | _, err := store.db.Exec(dropTablesDDL) 482 | if err != nil { 483 | panic(err) 484 | } 485 | } 486 | 487 | func init() { 488 | actionResult = make(map[string]string) 489 | } 490 | -------------------------------------------------------------------------------- /mtg/output.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "crypto/md5" 5 | "database/sql" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gofrs/uuid/v5" 12 | "github.com/shopspring/decimal" 13 | ) 14 | 15 | const ( 16 | OutputTypeSafeOutput = "kernel_output" 17 | 18 | SafeUtxoStateUnspent SafeUtxoState = "unspent" 19 | SafeUtxoStateAssigned SafeUtxoState = "assigned" 20 | SafeUtxoStateSigned SafeUtxoState = "signed" 21 | SafeUtxoStateSpent SafeUtxoState = "spent" 22 | ) 23 | 24 | type SafeUtxoState string 25 | 26 | type UnifiedOutput struct { 27 | Type string `json:"type"` 28 | OutputId string `json:"output_id"` 29 | TransactionRequestId string `json:"request_id,omitempty"` 30 | TransactionHash string `json:"transaction_hash"` 31 | OutputIndex int `json:"output_index"` 32 | AssetId string `json:"asset_id"` 33 | KernelAssetId string `json:"kernel_asset_id"` 34 | Amount decimal.Decimal `json:"amount"` 35 | SendersHash string `json:"senders_hash"` 36 | SendersThreshold int64 `json:"senders_threshold"` 37 | Senders []string `json:"senders"` 38 | ReceiversHash string `json:"receivers_hash"` 39 | ReceiversThreshold int64 `json:"receivers_threshold"` 40 | Extra string `json:"extra"` 41 | State SafeUtxoState `json:"state"` 42 | Sequence uint64 `json:"sequence"` 43 | Signers []string `json:"signers"` 44 | SignedBy string `json:"signed_by"` 45 | SequencerCreatedAt time.Time `json:"created_at"` 46 | 47 | updatedAt time.Time 48 | TraceId string 49 | AppId string 50 | } 51 | 52 | var outputCols = []string{"output_id", "request_id", "transaction_hash", "output_index", "asset_id", "kernel_asset_id", "amount", "senders_threshold", "senders", "receivers_threshold", "extra", "state", "sequence", "created_at", "updated_at", "signers", "signed_by", "trace_id", "app_id"} 53 | 54 | func (o *UnifiedOutput) values() []any { 55 | return []any{o.OutputId, o.TransactionRequestId, o.TransactionHash, o.OutputIndex, o.AssetId, o.KernelAssetId, o.Amount, o.SendersThreshold, strings.Join(o.Senders, ","), o.ReceiversThreshold, o.Extra, o.State, o.Sequence, o.SequencerCreatedAt, o.updatedAt, strings.Join(o.Signers, ","), o.SignedBy, o.TraceId, o.AppId} 56 | } 57 | 58 | func outputFromRow(row Row) (*UnifiedOutput, error) { 59 | var o UnifiedOutput 60 | var senders, signers string 61 | err := row.Scan(&o.OutputId, &o.TransactionRequestId, &o.TransactionHash, &o.OutputIndex, &o.AssetId, &o.KernelAssetId, &o.Amount, &o.SendersThreshold, &senders, &o.ReceiversThreshold, &o.Extra, &o.State, &o.Sequence, &o.SequencerCreatedAt, &o.updatedAt, &signers, &o.SignedBy, &o.TraceId, &o.AppId) 62 | if err == sql.ErrNoRows { 63 | return nil, nil 64 | } 65 | o.Senders = SplitIds(senders) 66 | o.Signers = SplitIds(signers) 67 | return &o, err 68 | } 69 | 70 | func (o *UnifiedOutput) checkId() bool { 71 | h := md5.New() 72 | oid := fmt.Sprintf("%s:%d", o.TransactionHash, o.OutputIndex) 73 | n, err := io.WriteString(h, oid) 74 | if err != nil || n != len(oid) { 75 | panic(err) 76 | } 77 | sum := h.Sum(nil) 78 | sum[6] = (sum[6] & 0x0f) | 0x30 79 | sum[8] = (sum[8] & 0x3f) | 0x80 80 | id, err := uuid.FromBytes(sum) 81 | if err != nil { 82 | panic(err) 83 | } 84 | return id.String() == o.OutputId 85 | } 86 | -------------------------------------------------------------------------------- /mtg/rpc.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/MixinNetwork/mixin/common" 12 | ) 13 | 14 | func GetKernelTransaction(rpc, hash string) (*common.VersionedTransaction, string, error) { 15 | raw, err := callMixinRPC(rpc, "gettransaction", []any{hash}) 16 | if err != nil || raw == nil { 17 | return nil, "", err 18 | } 19 | var signed map[string]any 20 | err = json.Unmarshal(raw, &signed) 21 | if err != nil { 22 | panic(string(raw)) 23 | } 24 | hex, err := hex.DecodeString(signed["hex"].(string)) 25 | if err != nil { 26 | panic(string(raw)) 27 | } 28 | ver, err := common.UnmarshalVersionedTransaction(hex) 29 | if err != nil { 30 | panic(string(raw)) 31 | } 32 | if signed["snapshot"] == nil { 33 | return ver, "", nil 34 | } 35 | return ver, signed["snapshot"].(string), nil 36 | } 37 | 38 | func callMixinRPC(node, method string, params []any) ([]byte, error) { 39 | client := &http.Client{Timeout: 20 * time.Second} 40 | 41 | body, err := json.Marshal(map[string]any{ 42 | "method": method, 43 | "params": params, 44 | }) 45 | if err != nil { 46 | panic(err) 47 | } 48 | req, err := http.NewRequest("POST", node, bytes.NewReader(body)) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | req.Close = true 54 | req.Header.Set("Content-Type", "application/json") 55 | resp, err := client.Do(req) 56 | if err != nil { 57 | return nil, err 58 | } 59 | defer resp.Body.Close() 60 | 61 | var result struct { 62 | Data any `json:"data"` 63 | Error any `json:"error"` 64 | } 65 | dec := json.NewDecoder(resp.Body) 66 | dec.UseNumber() 67 | err = dec.Decode(&result) 68 | if err != nil { 69 | return nil, err 70 | } 71 | if result.Error != nil { 72 | return nil, fmt.Errorf("callMixinRPC(%s, %s, %s) => %v", node, method, params, result.Error) 73 | } 74 | if result.Data == nil { 75 | return nil, nil 76 | } 77 | 78 | return json.Marshal(result.Data) 79 | } 80 | -------------------------------------------------------------------------------- /mtg/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS caches ( 2 | key VARCHAR NOT NULL, 3 | value VARCHAR NOT NULL, 4 | created_at TIMESTAMP NOT NULL, 5 | PRIMARY KEY ('key') 6 | ); 7 | 8 | CREATE INDEX IF NOT EXISTS caches_by_created ON caches(created_at); 9 | 10 | 11 | 12 | CREATE TABLE IF NOT EXISTS properties ( 13 | key VARCHAR NOT NULL, 14 | value VARCHAR NOT NULL, 15 | created_at TIMESTAMP NOT NULL, 16 | updated_at TIMESTAMP NOT NULL, 17 | PRIMARY KEY ('key') 18 | ); 19 | 20 | 21 | 22 | CREATE TABLE IF NOT EXISTS iterations ( 23 | node_id VARCHAR NOT NULL, 24 | action INTEGER NOT NULL, 25 | threshold INTEGER NOT NULL, 26 | created_at INTEGER NOT NULL, 27 | PRIMARY KEY ('node_id') 28 | ); 29 | 30 | CREATE INDEX IF NOT EXISTS iterations_by_node_created ON iterations(node_id, created_at); 31 | 32 | 33 | 34 | CREATE TABLE IF NOT EXISTS outputs ( 35 | output_id VARCHAR NOT NULL, 36 | request_id VARCHAR NOT NULL, 37 | transaction_hash VARCHAR NOT NULL, 38 | output_index INTEGER NOT NULL, 39 | asset_id VARCHAR NOT NULL, 40 | kernel_asset_id VARCHAR NOT NULL, 41 | amount VARCHAR NOT NULL, 42 | senders_threshold INTEGER NOT NULL, 43 | senders VARCHAR NOT NULL, 44 | receivers_threshold INTEGER NOT NULL, 45 | extra VARCHAR NOT NULL, 46 | state VARCHAR NOT NULL, 47 | sequence INTEGER NOT NULL, 48 | created_at TIMESTAMP NOT NULL, 49 | updated_at TIMESTAMP NOT NULL, 50 | signers VARCHAR NOT NULL, 51 | signed_by VARCHAR NOT NULL, 52 | trace_id VARCHAR NOT NULL, 53 | app_id VARCHAR NOT NULL, 54 | PRIMARY KEY ('output_id') 55 | ); 56 | 57 | CREATE UNIQUE INDEX IF NOT EXISTS outputs_by_sequence ON outputs(sequence); 58 | CREATE INDEX IF NOT EXISTS outputs_by_trace_sequence ON outputs(trace_id, sequence); 59 | CREATE INDEX IF NOT EXISTS outputs_by_hash_sequence ON outputs(transaction_hash, sequence); 60 | CREATE INDEX IF NOT EXISTS outputs_by_app_asset_state_sequence ON outputs(app_id, asset_id, state, sequence); 61 | CREATE INDEX IF NOT EXISTS outputs_by_transaction_hash_output_index ON outputs(transaction_hash, output_index); 62 | 63 | 64 | 65 | CREATE TABLE IF NOT EXISTS actions ( 66 | output_id VARCHAR NOT NULL, 67 | transaction_hash VARCHAR NOT NULL, 68 | action_state INTEGER NOT NULL, 69 | sequence INTEGER NOT NULL, 70 | restore_sequence INTEGER NOT NULL, 71 | PRIMARY KEY ('output_id') 72 | ); 73 | 74 | CREATE UNIQUE INDEX IF NOT EXISTS actions_by_sequence ON actions(sequence); 75 | CREATE INDEX IF NOT EXISTS actions_by_state_hash ON actions(action_state, transaction_hash); 76 | 77 | 78 | 79 | CREATE TABLE IF NOT EXISTS transactions ( 80 | trace_id VARCHAR NOT NULL, 81 | app_id VARCHAR NOT NULL, 82 | opponent_app_id VARCHAR NOT NULL, 83 | action_id VARCHAR NOT NULL, 84 | state INTEGER NOT NULL, 85 | asset_id VARCHAR NOT NULL, 86 | receivers VARCHAR NOT NULL, 87 | threshold INTEGER NOT NULL, 88 | amount VARCHAR NOT NULL, 89 | memo VARCHAR NOT NULL, 90 | raw VARCHAR, 91 | hash VARCHAR, 92 | refs VARCHAR NOT NULL, 93 | sequence INTEGER NOT NULL, 94 | compaction BOOLEAN NOT NULL, 95 | storage BOOLEAN NOT NULL, 96 | storage_trace_id VARCHAR, 97 | destination VARCHAR, 98 | tag VARCHAR, 99 | withdrawal_hash VARCHAR, 100 | request_id VARCHAR, 101 | updated_at TIMESTAMP NOT NULL, 102 | PRIMARY KEY ('trace_id') 103 | ); 104 | 105 | CREATE UNIQUE INDEX IF NOT EXISTS transactions_by_hash ON transactions(hash) WHERE hash IS NOT NULL; 106 | CREATE INDEX IF NOT EXISTS transactions_by_state_sequence ON transactions(state, sequence); 107 | CREATE INDEX IF NOT EXISTS transactions_by_state_sequence_hash ON transactions(state, sequence, hash); 108 | CREATE INDEX IF NOT EXISTS transactions_by_asset_state_sequence ON transactions(asset_id, state, sequence); 109 | CREATE INDEX IF NOT EXISTS withdrawal_transactions_by_state_hash_updated ON transactions(state, withdrawal_hash,updated_at); 110 | -------------------------------------------------------------------------------- /mtg/serialize.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/MixinNetwork/mixin/common" 9 | "github.com/MixinNetwork/mixin/crypto" 10 | "github.com/gofrs/uuid/v5" 11 | ) 12 | 13 | var ( 14 | null = []byte{0x00, 0x00} 15 | magic = []byte{0x77, 0x77} 16 | ) 17 | 18 | func writeByte(enc *common.Encoder, b int) { 19 | if b > 200 { 20 | panic(b) 21 | } 22 | err := enc.WriteByte(byte(b)) 23 | if err != nil { 24 | panic(err) 25 | } 26 | } 27 | 28 | func writeUuid(enc *common.Encoder, id string) { 29 | uid := uuid.FromStringOrNil(id) 30 | enc.Write(uid.Bytes()) 31 | } 32 | 33 | func writeString(enc *common.Encoder, str string) { 34 | data := []byte(str) 35 | enc.WriteInt(len(data)) 36 | enc.Write(data) 37 | } 38 | 39 | func writeBool(enc *common.Encoder, f bool) { 40 | data := 0 41 | if f { 42 | data = 1 43 | } 44 | writeByte(enc, data) 45 | } 46 | 47 | func writeReferences(enc *common.Encoder, refs []crypto.Hash) { 48 | writeByte(enc, len(refs)) 49 | for _, r := range refs { 50 | if !r.HasValue() { 51 | panic(fmt.Errorf("invalid ref %s", r.String())) 52 | } 53 | enc.Write(r[:]) 54 | } 55 | } 56 | 57 | func writeConsumed(enc *common.Encoder, consumed []*UnifiedOutput, consumedIds []string) { 58 | if len(consumed) > 0 && len(consumed) != len(consumedIds) { 59 | panic(len(consumedIds)) 60 | } 61 | writeByte(enc, len(consumedIds)) 62 | for _, id := range consumedIds { 63 | writeUuid(enc, id) 64 | } 65 | } 66 | 67 | func (tx *Transaction) Serialize() []byte { 68 | enc := common.NewEncoder() 69 | writeUuid(enc, tx.TraceId) 70 | writeUuid(enc, tx.AppId) 71 | writeUuid(enc, tx.OpponentAppId) 72 | writeUuid(enc, tx.ActionId) 73 | writeByte(enc, tx.State) 74 | writeUuid(enc, tx.AssetId) 75 | writeString(enc, tx.Amount) 76 | writeString(enc, tx.Memo) 77 | enc.WriteUint64(tx.Sequence) 78 | writeBool(enc, tx.compaction) 79 | writeBool(enc, tx.storage) 80 | writeReferences(enc, tx.references) 81 | writeUuid(enc, tx.storageTraceId) 82 | writeConsumed(enc, tx.consumed, tx.consumedIds) 83 | if tx.IsWithdrawal() { 84 | enc.Write(magic) 85 | writeString(enc, tx.Destination.String) 86 | writeString(enc, tx.Tag.String) 87 | } else { 88 | enc.Write(null) 89 | writeString(enc, strings.Join(tx.Receivers, ",")) 90 | writeByte(enc, tx.Threshold) 91 | } 92 | return enc.Bytes() 93 | } 94 | 95 | func readUuid(dec *common.Decoder) (string, error) { 96 | b := make([]byte, 16) 97 | err := dec.Read(b) 98 | if err != nil { 99 | return "", err 100 | } 101 | id, err := uuid.FromBytes(b) 102 | if err != nil { 103 | return "", err 104 | } 105 | return id.String(), nil 106 | } 107 | 108 | func readString(dec *common.Decoder) (string, error) { 109 | data, err := dec.ReadBytes() 110 | if err != nil { 111 | return "", err 112 | } 113 | return string(data), nil 114 | } 115 | 116 | func readBool(dec *common.Decoder) (bool, error) { 117 | f, err := dec.ReadByte() 118 | if err != nil { 119 | return false, err 120 | } 121 | return f == 1, nil 122 | } 123 | 124 | func readReferences(dec *common.Decoder) ([]crypto.Hash, error) { 125 | rl, err := dec.ReadByte() 126 | if err != nil { 127 | return nil, err 128 | } 129 | var refs []crypto.Hash 130 | for ; rl > 0; rl -= 1 { 131 | var r crypto.Hash 132 | err := dec.Read(r[:]) 133 | if err != nil { 134 | return nil, err 135 | } 136 | refs = append(refs, r) 137 | } 138 | return refs, nil 139 | } 140 | 141 | func readConsumed(dec *common.Decoder) ([]string, error) { 142 | cl, err := dec.ReadByte() 143 | if err != nil { 144 | return nil, err 145 | } 146 | var outputs []string 147 | for ; cl > 0; cl -= 1 { 148 | oid, err := readUuid(dec) 149 | if err != nil { 150 | return nil, err 151 | } 152 | outputs = append(outputs, oid) 153 | } 154 | return outputs, nil 155 | } 156 | 157 | func Deserialize(rb []byte) (*Transaction, error) { 158 | dec := common.NewDecoder(rb) 159 | 160 | traceId, err := readUuid(dec) 161 | if err != nil { 162 | return nil, err 163 | } 164 | appId, err := readUuid(dec) 165 | if err != nil { 166 | return nil, err 167 | } 168 | opponentAppId, err := readUuid(dec) 169 | if err != nil { 170 | return nil, err 171 | } 172 | actionId, err := readUuid(dec) 173 | if err != nil { 174 | return nil, err 175 | } 176 | state, err := dec.ReadByte() 177 | if err != nil { 178 | return nil, err 179 | } 180 | assetId, err := readUuid(dec) 181 | if err != nil { 182 | return nil, err 183 | } 184 | amount, err := readString(dec) 185 | if err != nil { 186 | return nil, err 187 | } 188 | memo, err := readString(dec) 189 | if err != nil { 190 | return nil, err 191 | } 192 | sequence, err := dec.ReadUint64() 193 | if err != nil { 194 | return nil, err 195 | } 196 | compaction, err := readBool(dec) 197 | if err != nil { 198 | return nil, err 199 | } 200 | storage, err := readBool(dec) 201 | if err != nil { 202 | return nil, err 203 | } 204 | refs, err := readReferences(dec) 205 | if err != nil { 206 | return nil, err 207 | } 208 | storageTraceId, err := readUuid(dec) 209 | if err != nil { 210 | return nil, err 211 | } 212 | ids, err := readConsumed(dec) 213 | if err != nil { 214 | return nil, err 215 | } 216 | tx := &Transaction{ 217 | TraceId: traceId, 218 | AppId: appId, 219 | OpponentAppId: opponentAppId, 220 | ActionId: actionId, 221 | State: int(state), 222 | AssetId: assetId, 223 | Amount: amount, 224 | Memo: memo, 225 | Sequence: sequence, 226 | compaction: compaction, 227 | storage: storage, 228 | references: refs, 229 | consumedIds: ids, 230 | } 231 | if storageTraceId != uuid.Nil.String() { 232 | tx.storageTraceId = storageTraceId 233 | } 234 | 235 | magic, err := dec.ReadMagic() 236 | if err != nil { 237 | return nil, err 238 | } 239 | if magic { 240 | destination, err := readString(dec) 241 | if err != nil { 242 | return nil, err 243 | } 244 | tag, err := readString(dec) 245 | if err != nil { 246 | return nil, err 247 | } 248 | tx.Destination = sql.NullString{Valid: true, String: destination} 249 | tx.Tag = sql.NullString{Valid: true, String: tag} 250 | } else { 251 | receivers, err := readString(dec) 252 | if err != nil { 253 | return nil, err 254 | } 255 | threshold, err := dec.ReadByte() 256 | if err != nil { 257 | return nil, err 258 | } 259 | tx.Receivers = SplitIds(receivers) 260 | tx.Threshold = int(threshold) 261 | } 262 | return tx, nil 263 | } 264 | 265 | func SerializeTransactions(txs []*Transaction) []byte { 266 | enc := common.NewEncoder() 267 | writeByte(enc, len(txs)) 268 | for _, tx := range txs { 269 | b := tx.Serialize() 270 | enc.WriteInt(len(b)) 271 | enc.Write(b) 272 | } 273 | return enc.Bytes() 274 | } 275 | 276 | func DeserializeTransactions(tb []byte) ([]*Transaction, error) { 277 | dec := common.NewDecoder(tb) 278 | count, err := dec.ReadByte() 279 | if err != nil || count == 0 { 280 | return nil, err 281 | } 282 | txs := make([]*Transaction, count) 283 | for i := 0; i < int(count); i++ { 284 | b, err := dec.ReadBytes() 285 | if err != nil { 286 | return nil, err 287 | } 288 | tx, err := Deserialize(b) 289 | if err != nil { 290 | return nil, err 291 | } 292 | txs[i] = tx 293 | } 294 | return txs, nil 295 | } 296 | -------------------------------------------------------------------------------- /mtg/sqlite3.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | _ "embed" 7 | "fmt" 8 | "os" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/MixinNetwork/mixin/logger" 14 | _ "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | //go:embed schema.sql 18 | var SCHEMA string 19 | 20 | type SQLite3Store struct { 21 | db *sql.DB 22 | mutex *sync.RWMutex 23 | } 24 | 25 | func ExpandTilde(path string) string { 26 | if !strings.HasPrefix(path, "~/") { 27 | return path 28 | } 29 | home, err := os.UserHomeDir() 30 | if err != nil { 31 | panic(err) 32 | } 33 | path = strings.Replace(path, "~", home, 1) 34 | return path 35 | } 36 | 37 | func OpenSQLite3Store(path string) (*SQLite3Store, error) { 38 | path = ExpandTilde(path) 39 | dsn := fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL&cache=private", path) 40 | db, err := sql.Open("sqlite3", dsn) 41 | if err != nil { 42 | return nil, err 43 | } 44 | _, err = db.Exec(SCHEMA) 45 | if err != nil { 46 | return nil, err 47 | } 48 | err = db.Ping() 49 | if err != nil { 50 | return nil, err 51 | } 52 | return &SQLite3Store{ 53 | db: db, 54 | mutex: new(sync.RWMutex), 55 | }, nil 56 | } 57 | 58 | func (s *SQLite3Store) Close() error { 59 | return s.db.Close() 60 | } 61 | 62 | func (s *SQLite3Store) execOne(ctx context.Context, tx *sql.Tx, sql string, params ...any) error { 63 | return s.execMultiple(ctx, tx, 1, sql, params...) 64 | } 65 | 66 | func (s *SQLite3Store) execMultiple(ctx context.Context, tx *sql.Tx, num int64, sql string, params ...any) error { 67 | res, err := tx.ExecContext(ctx, sql, params...) 68 | logger.Verbosef("SQLite3Store.ExecContext(%s, %v) => %v", sql, params, err) 69 | if err != nil { 70 | return err 71 | } 72 | rows, err := res.RowsAffected() 73 | if err != nil || rows != num { 74 | return fmt.Errorf("exec(%d, %s) => %d %v", num, sql, rows, err) 75 | } 76 | return nil 77 | } 78 | 79 | func buildInsertionSQL(table string, cols []string) string { 80 | vals := strings.Repeat("?, ", len(cols)) 81 | return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(cols, ","), vals[:len(vals)-2]) 82 | } 83 | 84 | func (s *SQLite3Store) checkExistence(ctx context.Context, tx *sql.Tx, sql string, params ...any) (bool, error) { 85 | rows, err := tx.QueryContext(ctx, sql, params...) 86 | if err != nil { 87 | return false, err 88 | } 89 | defer rows.Close() 90 | 91 | return rows.Next(), nil 92 | } 93 | 94 | func (s *SQLite3Store) ReadProperty(ctx context.Context, k string) (string, error) { 95 | s.mutex.RLock() 96 | defer s.mutex.RUnlock() 97 | 98 | row := s.db.QueryRowContext(ctx, "SELECT value FROM properties WHERE key=?", k) 99 | var value string 100 | err := row.Scan(&value) 101 | if err == sql.ErrNoRows { 102 | return "", nil 103 | } 104 | return value, err 105 | } 106 | 107 | func (s *SQLite3Store) WriteProperty(ctx context.Context, k, v string) error { 108 | s.mutex.Lock() 109 | defer s.mutex.Unlock() 110 | 111 | tx, err := s.db.BeginTx(ctx, nil) 112 | if err != nil { 113 | return err 114 | } 115 | defer rollBack(tx) 116 | 117 | existed, err := s.checkExistence(ctx, tx, "SELECT value FROM properties WHERE key=?", k) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | createdAt := time.Now().UTC() 123 | if existed { 124 | err = s.execOne(ctx, tx, "UPDATE properties SET value=?, updated_at=? WHERE key=?", v, createdAt, k) 125 | if err != nil { 126 | return fmt.Errorf("UPDATE properties %v", err) 127 | } 128 | } else { 129 | cols := []string{"key", "value", "created_at", "updated_at"} 130 | err = s.execOne(ctx, tx, buildInsertionSQL("properties", cols), k, v, createdAt, createdAt) 131 | if err != nil { 132 | return fmt.Errorf("INSERT properties %v", err) 133 | } 134 | } 135 | return tx.Commit() 136 | } 137 | -------------------------------------------------------------------------------- /mtg/store.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/hex" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/MixinNetwork/mixin/crypto" 12 | "github.com/gofrs/uuid/v5" 13 | ) 14 | 15 | func (s *SQLite3Store) ListActions(ctx context.Context, state ActionState, limit int) ([]*Action, error) { 16 | query := fmt.Sprintf("SELECT %s FROM actions JOIN outputs ON actions.output_id=outputs.output_id WHERE action_state=? ORDER BY actions.sequence ASC", strings.Join(actionJoinCols, ",")) 17 | if limit > 0 { 18 | query += fmt.Sprintf(" LIMIT %d", limit) 19 | } 20 | rows, err := s.db.QueryContext(ctx, query, state) 21 | if err != nil { 22 | return nil, err 23 | } 24 | defer rows.Close() 25 | 26 | var as []*Action 27 | for rows.Next() { 28 | a, err := actionJoinFromRow(rows) 29 | if err != nil { 30 | return nil, err 31 | } 32 | as = append(as, a) 33 | } 34 | return as, nil 35 | } 36 | 37 | func (s *SQLite3Store) readOutput(ctx context.Context, tx *sql.Tx, id string) (*UnifiedOutput, error) { 38 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE output_id=?", strings.Join(outputCols, ",")) 39 | row := tx.QueryRowContext(ctx, query, id) 40 | return outputFromRow(row) 41 | } 42 | 43 | func (s *SQLite3Store) ReadOutputByHashAndIndex(ctx context.Context, hash string, index uint) (*UnifiedOutput, error) { 44 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE transaction_hash=? AND output_index=?", strings.Join(outputCols, ",")) 45 | row := s.db.QueryRowContext(ctx, query, hash, index) 46 | return outputFromRow(row) 47 | } 48 | 49 | func (s *SQLite3Store) readAction(ctx context.Context, tx *sql.Tx, id string) (*Action, error) { 50 | query := fmt.Sprintf("SELECT %s FROM actions WHERE output_id=?", strings.Join(actionCols, ",")) 51 | row := tx.QueryRowContext(ctx, query, id) 52 | return actionFromRow(row) 53 | } 54 | 55 | func (s *SQLite3Store) readRestorableAction(ctx context.Context, txn *sql.Tx, t *Transaction) (*Action, error) { 56 | if len(t.references) != 1 { 57 | return nil, nil 58 | } 59 | hash := t.references[0].String() 60 | query := fmt.Sprintf("SELECT %s FROM actions WHERE action_state=? AND transaction_hash=?", strings.Join(actionCols, ",")) 61 | row := txn.QueryRowContext(ctx, query, ActionStateRestorable, hash) 62 | return actionFromRow(row) 63 | } 64 | 65 | func (s *SQLite3Store) finishAction(ctx context.Context, tx *sql.Tx, id string, state ActionState, ts []*Transaction) error { 66 | act, err := s.readAction(ctx, tx, id) 67 | if err != nil || act == nil || act.ActionState != ActionStateInitial { 68 | return fmt.Errorf("invalid action to finish => %v %v", act, err) 69 | } 70 | 71 | err = s.execOne(ctx, tx, "UPDATE actions SET action_state=? WHERE output_id=? AND action_state=?", state, id, ActionStateInitial) 72 | if err != nil { 73 | return fmt.Errorf("UPDATE actions %v", err) 74 | } 75 | 76 | for _, t := range ts { 77 | if len(t.consumed) == 0 { 78 | panic(t.TraceId) 79 | } 80 | if t.State != TransactionStateInitial { 81 | panic(t.TraceId) 82 | } 83 | if t.IsStorage() { 84 | if t.AssetId != StorageAssetId || t.Threshold != 64 || len(t.Receivers) != 1 { 85 | return fmt.Errorf("invalid storage transaction: %#v", t) 86 | } 87 | } 88 | sequence := act.Sequence 89 | if act.restoreSequence > act.Sequence { 90 | sequence = act.restoreSequence 91 | } 92 | if t.Sequence != sequence { 93 | panic(t.Sequence) 94 | } 95 | 96 | existed, err := s.checkExistence(ctx, tx, "SELECT trace_id FROM transactions WHERE trace_id=?", t.TraceId) 97 | if err != nil { 98 | return err 99 | } 100 | if existed { 101 | continue 102 | } 103 | 104 | err = s.execOne(ctx, tx, buildInsertionSQL("transactions", transactionCols), t.values()...) 105 | if err != nil { 106 | return fmt.Errorf("INSERT transactions %v", err) 107 | } 108 | 109 | for _, o := range t.consumed { 110 | query := "UPDATE outputs SET state=?,trace_id=?,updated_at=? WHERE output_id=? AND state=?" 111 | err = s.execOne(ctx, tx, query, SafeUtxoStateAssigned, t.TraceId, time.Now().UTC(), o.OutputId, SafeUtxoStateUnspent) 112 | if err != nil { 113 | return fmt.Errorf("UPDATE outputs %v", err) 114 | } 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (s *SQLite3Store) FinishAction(ctx context.Context, id string, state ActionState, ts []*Transaction) error { 122 | s.mutex.Lock() 123 | defer s.mutex.Unlock() 124 | 125 | tx, err := s.db.BeginTx(ctx, nil) 126 | if err != nil { 127 | return err 128 | } 129 | defer rollBack(tx) 130 | 131 | err = s.finishAction(ctx, tx, id, state, ts) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | return tx.Commit() 137 | } 138 | 139 | func (s *SQLite3Store) writeOutputAndAction(ctx context.Context, tx *sql.Tx, out *UnifiedOutput, state ActionState) error { 140 | if out.State != SafeUtxoStateUnspent { 141 | panic(out.OutputId) 142 | } 143 | aid := uuid.Must(uuid.FromString(out.AppId)) 144 | if aid.String() != out.AppId { 145 | panic(out.AppId) 146 | } 147 | 148 | oldAct, err := s.readAction(ctx, tx, out.OutputId) 149 | if err != nil { 150 | return err 151 | } 152 | oldOutput, err := s.readOutput(ctx, tx, out.OutputId) 153 | if err != nil { 154 | return err 155 | } 156 | switch { 157 | case oldAct == nil && oldOutput == nil: 158 | case oldAct != nil && oldOutput != nil: 159 | return nil 160 | default: 161 | reason := fmt.Errorf("action or output exists: %v %v", oldAct, oldOutput) 162 | panic(reason) 163 | } 164 | 165 | out.updatedAt = time.Now().UTC() 166 | err = s.execOne(ctx, tx, buildInsertionSQL("outputs", outputCols), out.values()...) 167 | if err != nil { 168 | return fmt.Errorf("INSERT outputs %v", err) 169 | } 170 | 171 | a := Action{ 172 | ActionState: state, 173 | restoreSequence: 0, 174 | } 175 | a.Sequence = out.Sequence 176 | a.OutputId = out.OutputId 177 | a.TransactionHash = out.TransactionHash 178 | err = s.execOne(ctx, tx, buildInsertionSQL("actions", actionCols), a.values()...) 179 | if err != nil { 180 | return fmt.Errorf("INSERT actions %v", err) 181 | } 182 | 183 | return nil 184 | } 185 | 186 | func (s *SQLite3Store) WriteAction(ctx context.Context, out *UnifiedOutput, state ActionState) error { 187 | s.mutex.Lock() 188 | defer s.mutex.Unlock() 189 | 190 | tx, err := s.db.BeginTx(ctx, nil) 191 | if err != nil { 192 | return err 193 | } 194 | defer rollBack(tx) 195 | 196 | err = s.writeOutputAndAction(ctx, tx, out, state) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | return tx.Commit() 202 | } 203 | 204 | func (s *SQLite3Store) RestoreAction(ctx context.Context, act *Action, t *Transaction) error { 205 | s.mutex.Lock() 206 | defer s.mutex.Unlock() 207 | 208 | tx, err := s.db.BeginTx(ctx, nil) 209 | if err != nil { 210 | return err 211 | } 212 | defer rollBack(tx) 213 | 214 | rAct, err := s.readRestorableAction(ctx, tx, t) 215 | if err != nil || rAct == nil { 216 | return fmt.Errorf("readRestorableAction(%v) => %v %v", t, rAct, err) 217 | } 218 | 219 | query := "UPDATE actions SET action_state=?,restore_sequence=? WHERE output_id=? AND action_state=?" 220 | err = s.execOne(ctx, tx, query, ActionStateInitial, act.Sequence, rAct.OutputId, ActionStateRestorable) 221 | if err != nil { 222 | return fmt.Errorf("UPDATE actions %v", err) 223 | } 224 | 225 | err = s.finishAction(ctx, tx, act.OutputId, ActionStateDone, nil) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | return tx.Commit() 231 | } 232 | 233 | func (s *SQLite3Store) listOutputs(ctx context.Context, ids []string) ([]*UnifiedOutput, error) { 234 | cols := strings.Join(outputCols, ",") 235 | sets := "'" + strings.Join(ids, "','") + "'" 236 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE output_id IN (%s) ORDER BY sequence ASC", cols, sets) 237 | rows, err := s.db.QueryContext(ctx, query) 238 | if err != nil { 239 | return nil, err 240 | } 241 | defer rows.Close() 242 | 243 | var os []*UnifiedOutput 244 | for rows.Next() { 245 | o, err := outputFromRow(rows) 246 | if err != nil { 247 | return nil, err 248 | } 249 | os = append(os, o) 250 | } 251 | return os, nil 252 | } 253 | 254 | func (s *SQLite3Store) ListOutputsForTransaction(ctx context.Context, traceId string, sequence uint64) ([]*UnifiedOutput, error) { 255 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE trace_id=? AND sequence<=? ORDER BY trace_id, sequence ASC", strings.Join(outputCols, ",")) 256 | rows, err := s.db.QueryContext(ctx, query, traceId, sequence) 257 | if err != nil { 258 | return nil, err 259 | } 260 | defer rows.Close() 261 | 262 | var os []*UnifiedOutput 263 | for rows.Next() { 264 | o, err := outputFromRow(rows) 265 | if err != nil { 266 | return nil, err 267 | } 268 | os = append(os, o) 269 | } 270 | return os, nil 271 | } 272 | 273 | func (s *SQLite3Store) ListOutputsByTransactionHash(ctx context.Context, hash string, sequence uint64) ([]*UnifiedOutput, error) { 274 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE transaction_hash=? AND sequence<=? ORDER BY transaction_hash, sequence ASC", strings.Join(outputCols, ",")) 275 | rows, err := s.db.QueryContext(ctx, query, hash, sequence) 276 | if err != nil { 277 | return nil, err 278 | } 279 | defer rows.Close() 280 | 281 | var os []*UnifiedOutput 282 | for rows.Next() { 283 | o, err := outputFromRow(rows) 284 | if err != nil { 285 | return nil, err 286 | } 287 | os = append(os, o) 288 | } 289 | return os, nil 290 | } 291 | 292 | func (s *SQLite3Store) ListOutputsForAsset(ctx context.Context, appId, assetId string, consumedUntil, sequence uint64, state SafeUtxoState, limit int) ([]*UnifiedOutput, error) { 293 | query := fmt.Sprintf("SELECT %s FROM outputs WHERE app_id=? AND asset_id=? AND state=? AND sequence>? AND sequence<=? ORDER BY app_id, asset_id, state, sequence ASC", strings.Join(outputCols, ",")) 294 | if limit > 0 { 295 | query += fmt.Sprintf(" LIMIT %d", limit) 296 | } 297 | rows, err := s.db.QueryContext(ctx, query, appId, assetId, state, consumedUntil, sequence) 298 | if err != nil { 299 | return nil, err 300 | } 301 | defer rows.Close() 302 | 303 | var os []*UnifiedOutput 304 | for rows.Next() { 305 | o, err := outputFromRow(rows) 306 | if err != nil { 307 | return nil, err 308 | } 309 | os = append(os, o) 310 | } 311 | return os, nil 312 | } 313 | 314 | func (s *SQLite3Store) UpdateTxWithOutputs(ctx context.Context, t *Transaction, os []*UnifiedOutput) error { 315 | s.mutex.Lock() 316 | defer s.mutex.Unlock() 317 | 318 | tx, err := s.db.BeginTx(ctx, nil) 319 | if err != nil { 320 | return err 321 | } 322 | defer rollBack(tx) 323 | 324 | var refs []string 325 | for _, r := range t.references { 326 | refs = append(refs, r.String()) 327 | } 328 | 329 | err = s.execOne(ctx, tx, "UPDATE transactions SET raw=?,hash=?,refs=?,state=?,request_id=?,updated_at=? WHERE trace_id=? AND state=?", 330 | hex.EncodeToString(t.Raw), t.Hash.String(), strings.Join(refs, ","), t.State, t.requestId, t.UpdatedAt, t.TraceId, TransactionStateInitial) 331 | if err != nil { 332 | return fmt.Errorf("UPDATE transactions %v", err) 333 | } 334 | 335 | for _, o := range os { 336 | query := "UPDATE outputs SET state=?,signed_by=?,updated_at=? WHERE output_id=? AND state=? AND trace_id=?" 337 | err = s.execOne(ctx, tx, query, o.State, o.SignedBy, t.UpdatedAt, o.OutputId, SafeUtxoStateAssigned, t.TraceId) 338 | if err != nil { 339 | return fmt.Errorf("UPDATE outputs %v", err) 340 | } 341 | } 342 | 343 | return tx.Commit() 344 | } 345 | 346 | func (s *SQLite3Store) FinishTransaction(ctx context.Context, traceId string) error { 347 | s.mutex.Lock() 348 | defer s.mutex.Unlock() 349 | 350 | tx, err := s.db.BeginTx(ctx, nil) 351 | if err != nil { 352 | return err 353 | } 354 | defer rollBack(tx) 355 | 356 | err = s.execOne(ctx, tx, "UPDATE transactions SET state=?, updated_at=? WHERE trace_id=? AND state=?", 357 | TransactionStateSnapshot, time.Now(), traceId, TransactionStateSigned) 358 | if err != nil { 359 | return fmt.Errorf("UPDATE transactions %v", err) 360 | } 361 | 362 | _, err = tx.ExecContext(ctx, "UPDATE outputs SET state=?,updated_at=? WHERE trace_id=? AND state=?", 363 | SafeUtxoStateSpent, time.Now().UTC(), traceId, SafeUtxoStateSigned) 364 | if err != nil { 365 | return fmt.Errorf("UPDATE outputs %v", err) 366 | } 367 | 368 | return tx.Commit() 369 | } 370 | 371 | func (s *SQLite3Store) ConfirmWithdrawalTransaction(ctx context.Context, traceId, hash string) error { 372 | s.mutex.Lock() 373 | defer s.mutex.Unlock() 374 | 375 | tx, err := s.db.BeginTx(ctx, nil) 376 | if err != nil { 377 | return err 378 | } 379 | defer rollBack(tx) 380 | 381 | err = s.execOne(ctx, tx, "UPDATE transactions SET withdrawal_hash=?, updated_at=? WHERE trace_id=? AND state=? AND destination IS NOT NULL AND withdrawal_hash IS NULL", 382 | hash, time.Now(), traceId, TransactionStateSnapshot) 383 | if err != nil { 384 | return fmt.Errorf("UPDATE transactions %v", err) 385 | } 386 | 387 | return tx.Commit() 388 | } 389 | 390 | func (s *SQLite3Store) readIteration(ctx context.Context, txn *sql.Tx, id string) (*Iteration, error) { 391 | query := fmt.Sprintf("SELECT %s FROM iterations WHERE node_id=?", strings.Join(iterationCols, ",")) 392 | row := txn.QueryRowContext(ctx, query, id) 393 | return iterationFromRow(row) 394 | } 395 | 396 | func (s *SQLite3Store) ListIterations(ctx context.Context) ([]*Iteration, error) { 397 | query := fmt.Sprintf("SELECT %s FROM iterations ORDER BY node_id,created_at ASC", strings.Join(iterationCols, ",")) 398 | rows, err := s.db.QueryContext(ctx, query) 399 | if err != nil { 400 | return nil, err 401 | } 402 | defer rows.Close() 403 | 404 | var irs []*Iteration 405 | for rows.Next() { 406 | i, err := iterationFromRow(rows) 407 | if err != nil { 408 | return nil, err 409 | } 410 | irs = append(irs, i) 411 | } 412 | return irs, nil 413 | } 414 | 415 | func (s *SQLite3Store) WriteIteration(ctx context.Context, ir *Iteration) error { 416 | s.mutex.Lock() 417 | defer s.mutex.Unlock() 418 | 419 | tx, err := s.db.BeginTx(ctx, nil) 420 | if err != nil { 421 | return err 422 | } 423 | defer rollBack(tx) 424 | 425 | old, err := s.readIteration(ctx, tx, ir.NodeId) 426 | if err != nil { 427 | return err 428 | } 429 | if old != nil && old.Action >= ir.Action { 430 | return nil 431 | } 432 | 433 | if old != nil { 434 | err = s.execOne(ctx, tx, "UPDATE iterations SET action=?, threshold=?, created_at=? WHERE node_id=?", ir.Action, ir.Threshold, ir.CreatedAt, ir.NodeId) 435 | if err != nil { 436 | return fmt.Errorf("UPDATE iterations %v", err) 437 | } 438 | } else { 439 | err = s.execOne(ctx, tx, buildInsertionSQL("iterations", iterationCols), ir.values()...) 440 | if err != nil { 441 | return fmt.Errorf("INSERT iterations %v", err) 442 | } 443 | } 444 | 445 | return tx.Commit() 446 | } 447 | 448 | func (s *SQLite3Store) ListPreviousInitialTransactions(ctx context.Context, asset string, sequence uint64) ([]*Transaction, error) { 449 | query := fmt.Sprintf("SELECT %s FROM transactions where asset_id=? AND state=? AND sequence<=? ORDER BY asset_id, state, sequence ASC", strings.Join(transactionCols, ",")) 450 | rows, err := s.db.QueryContext(ctx, query, asset, sequence, TransactionStateInitial) 451 | if err != nil { 452 | return nil, err 453 | } 454 | defer rows.Close() 455 | 456 | var ts []*Transaction 457 | for rows.Next() { 458 | t, err := transactionFromRow(rows) 459 | if err != nil { 460 | return nil, err 461 | } 462 | ts = append(ts, t) 463 | } 464 | return ts, nil 465 | } 466 | 467 | func (s *SQLite3Store) ListTransactions(ctx context.Context, state, limit int) ([]*Transaction, map[string][]*Transaction, error) { 468 | query := fmt.Sprintf("SELECT %s FROM transactions where state=? ORDER BY state,sequence,trace_id ASC", strings.Join(transactionCols, ",")) 469 | if limit > 0 { 470 | query += fmt.Sprintf(" LIMIT %d", limit) 471 | } 472 | rows, err := s.db.QueryContext(ctx, query, state) 473 | if err != nil { 474 | return nil, nil, err 475 | } 476 | defer rows.Close() 477 | 478 | var ts []*Transaction 479 | assetTxMap := make(map[string][]*Transaction) 480 | for rows.Next() { 481 | t, err := transactionFromRow(rows) 482 | if err != nil { 483 | return nil, nil, err 484 | } 485 | ts = append(ts, t) 486 | assetTxMap[t.AssetId] = append(assetTxMap[t.AssetId], t) 487 | } 488 | return ts, assetTxMap, nil 489 | } 490 | 491 | func (s *SQLite3Store) ListUnconfirmedWithdrawalTransactions(ctx context.Context, limit int) ([]*Transaction, error) { 492 | query := fmt.Sprintf("SELECT %s FROM transactions where state=? AND destination IS NOT NULL AND withdrawal_hash IS NULL ORDER BY state,sequence,trace_id ASC", strings.Join(transactionCols, ",")) 493 | if limit > 0 { 494 | query += fmt.Sprintf(" LIMIT %d", limit) 495 | } 496 | rows, err := s.db.QueryContext(ctx, query, TransactionStateSnapshot) 497 | if err != nil { 498 | return nil, err 499 | } 500 | defer rows.Close() 501 | 502 | var ts []*Transaction 503 | for rows.Next() { 504 | t, err := transactionFromRow(rows) 505 | if err != nil { 506 | return nil, err 507 | } 508 | ts = append(ts, t) 509 | } 510 | return ts, nil 511 | } 512 | 513 | func (s *SQLite3Store) ListConfirmedWithdrawalTransactionsAfter(ctx context.Context, offset time.Time, limit int) ([]*Transaction, error) { 514 | query := fmt.Sprintf("SELECT %s FROM transactions where state=? AND withdrawal_hash IS NOT NULL AND updated_at>? ORDER BY updated_at ASC", strings.Join(transactionCols, ",")) 515 | if limit > 0 { 516 | query += fmt.Sprintf(" LIMIT %d", limit) 517 | } 518 | rows, err := s.db.QueryContext(ctx, query, TransactionStateSnapshot, offset) 519 | if err != nil { 520 | return nil, err 521 | } 522 | defer rows.Close() 523 | 524 | var ts []*Transaction 525 | for rows.Next() { 526 | t, err := transactionFromRow(rows) 527 | if err != nil { 528 | return nil, err 529 | } 530 | ts = append(ts, t) 531 | } 532 | return ts, nil 533 | } 534 | 535 | func (s *SQLite3Store) ReadTransactionByHash(ctx context.Context, hash crypto.Hash) (*Transaction, error) { 536 | query := fmt.Sprintf("SELECT %s FROM transactions WHERE hash=?", strings.Join(transactionCols, ",")) 537 | row := s.db.QueryRowContext(ctx, query, hash.String()) 538 | return transactionFromRow(row) 539 | } 540 | 541 | func (s *SQLite3Store) ReadTransactionByTraceId(ctx context.Context, id string) (*Transaction, error) { 542 | query := fmt.Sprintf("SELECT %s FROM transactions WHERE trace_id=?", strings.Join(transactionCols, ",")) 543 | row := s.db.QueryRowContext(ctx, query, id) 544 | return transactionFromRow(row) 545 | } 546 | 547 | type Row interface { 548 | Scan(dest ...any) error 549 | } 550 | 551 | func rollBack(txn *sql.Tx) { 552 | err := txn.Rollback() 553 | const already = "transaction has already been committed or rolled back" 554 | if err != nil && !strings.Contains(err.Error(), already) { 555 | panic(err) 556 | } 557 | } 558 | -------------------------------------------------------------------------------- /mtg/transaction.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "encoding/hex" 8 | "fmt" 9 | "slices" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/MixinNetwork/mixin/common" 15 | "github.com/MixinNetwork/mixin/crypto" 16 | "github.com/MixinNetwork/mixin/logger" 17 | "github.com/fox-one/mixin-sdk-go/v2" 18 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 19 | "github.com/gofrs/uuid/v5" 20 | "github.com/shopspring/decimal" 21 | ) 22 | 23 | const ( 24 | TransactionStateInitial = 10 25 | TransactionStateSigned = 12 26 | TransactionStateSnapshot = 13 27 | 28 | OutputsBatchSize = 36 29 | StorageAssetId = "c94ac88f-4671-3976-b60a-09064f1811e8" 30 | MixinFeeUserId = "674d6776-d600-4346-af46-58e77d8df185" 31 | ) 32 | 33 | type TransactionRecipient struct { 34 | MixAddress *mixin.MixAddress 35 | Amount string 36 | UuidMember bool 37 | 38 | Destination string 39 | Tag string 40 | } 41 | 42 | type Transaction struct { 43 | TraceId string 44 | AppId string 45 | OpponentAppId string 46 | ActionId string 47 | State int 48 | AssetId string 49 | Receivers []string 50 | Threshold int 51 | Amount string 52 | Memo string 53 | Raw []byte 54 | Hash crypto.Hash 55 | Sequence uint64 56 | UpdatedAt time.Time 57 | 58 | compaction bool 59 | storage bool 60 | references []crypto.Hash 61 | storageTraceId string 62 | Destination sql.NullString 63 | Tag sql.NullString 64 | WithdrawalHash sql.NullString 65 | requestId sql.NullString 66 | consumed []*UnifiedOutput 67 | consumedIds []string 68 | } 69 | 70 | var transactionCols = []string{ 71 | "trace_id", "app_id", "opponent_app_id", "action_id", "state", "asset_id", "receivers", "threshold", "amount", "memo", "raw", "hash", "refs", "sequence", 72 | "compaction", "storage", "storage_trace_id", "destination", "tag", "withdrawal_hash", "request_id", "updated_at", 73 | } 74 | 75 | func (t *Transaction) values() []any { 76 | var refs []string 77 | for _, r := range t.references { 78 | refs = append(refs, r.String()) 79 | } 80 | var hash, raw sql.NullString 81 | if t.Hash.HasValue() { 82 | hash = sql.NullString{Valid: true, String: t.Hash.String()} 83 | raw = sql.NullString{Valid: true, String: hex.EncodeToString(t.Raw)} 84 | } 85 | return []any{ 86 | t.TraceId, t.AppId, t.OpponentAppId, t.ActionId, t.State, t.AssetId, strings.Join(t.Receivers, ","), t.Threshold, t.Amount, t.Memo, raw, hash, strings.Join(refs, ","), t.Sequence, 87 | t.compaction, t.storage, t.storageTraceId, t.Destination, t.Tag, t.WithdrawalHash, t.requestId, t.UpdatedAt, 88 | } 89 | } 90 | 91 | func (t *Transaction) IsStorage() bool { 92 | return t.storage 93 | } 94 | 95 | func (t *Transaction) IsWithdrawal() bool { 96 | return t.Destination.Valid 97 | } 98 | 99 | func (t *Transaction) IsNormal() bool { 100 | return !t.IsStorage() && !t.IsWithdrawal() 101 | } 102 | 103 | func transactionFromRow(row Row) (*Transaction, error) { 104 | var t Transaction 105 | var rs, refs string 106 | var hash, raw sql.NullString 107 | err := row.Scan( 108 | &t.TraceId, &t.AppId, &t.OpponentAppId, &t.ActionId, &t.State, &t.AssetId, &rs, &t.Threshold, &t.Amount, &t.Memo, &raw, &hash, &refs, &t.Sequence, 109 | &t.compaction, &t.storage, &t.storageTraceId, &t.Destination, &t.Tag, &t.WithdrawalHash, &t.requestId, &t.UpdatedAt) 110 | if err == sql.ErrNoRows { 111 | return nil, nil 112 | } 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | if hash.Valid { 118 | h, err := crypto.HashFromString(hash.String) 119 | if err != nil { 120 | panic(hash.String) 121 | } 122 | r, err := hex.DecodeString(raw.String) 123 | if err != nil { 124 | panic(raw.String) 125 | } 126 | t.Hash = h 127 | t.Raw = r 128 | } 129 | 130 | t.Receivers = SplitIds(rs) 131 | for _, r := range SplitIds(refs) { 132 | ref, err := crypto.HashFromString(r) 133 | if err != nil { 134 | return nil, err 135 | } 136 | t.references = append(t.references, ref) 137 | } 138 | return &t, nil 139 | } 140 | 141 | func (act *Action) BuildTransaction(ctx context.Context, traceId, opponentAppId, assetId, amount, memo string, receivers []string, threshold int) *Transaction { 142 | rs := make([]string, len(receivers)) 143 | copy(rs, receivers) 144 | sort.Strings(rs) 145 | 146 | tx := &Transaction{ 147 | TraceId: traceId, 148 | OpponentAppId: opponentAppId, 149 | ActionId: act.OutputId, 150 | State: TransactionStateInitial, 151 | AssetId: assetId, 152 | Amount: amount, 153 | Receivers: receivers, 154 | Threshold: threshold, 155 | Memo: memo, 156 | AppId: act.AppId, 157 | Sequence: act.Sequence, 158 | } 159 | outputs := act.group.ListOutputsForAsset(ctx, tx.AppId, tx.AssetId, act.consumed[assetId], tx.Sequence, SafeUtxoStateUnspent, OutputsBatchSize) 160 | if len(outputs) == 0 { 161 | panic(tx.TraceId) 162 | } 163 | if ids := safeTransactionSequenceOrderHack[tx.TraceId]; len(ids) > 0 { 164 | hack, err := act.group.store.listOutputs(ctx, ids) 165 | if err != nil { 166 | panic(err) 167 | } 168 | outputs = hack 169 | } 170 | inputs, _, err := act.group.getTransactionInputsAndRecipients(ctx, tx, outputs) 171 | if err != nil { 172 | panic(err) 173 | } 174 | tx.consumed = inputs 175 | for _, o := range tx.consumed { 176 | tx.consumedIds = append(tx.consumedIds, o.OutputId) 177 | if o.Sequence > act.consumed[assetId] { 178 | act.consumed[assetId] = o.Sequence 179 | } 180 | } 181 | return tx 182 | } 183 | 184 | func (act *Action) BuildTransactionWithReference(ctx context.Context, traceId, opponentAppId, assetId, amount, memo string, receivers []string, threshold int, reference crypto.Hash) *Transaction { 185 | if !reference.HasValue() { 186 | panic(reference) 187 | } 188 | t := act.BuildTransaction(ctx, traceId, opponentAppId, assetId, amount, memo, receivers, threshold) 189 | t.references = []crypto.Hash{reference} 190 | return t 191 | } 192 | 193 | func (act *Action) BuildTransactionWithStorageTraceId(ctx context.Context, traceId, opponentAppId, assetId, amount, memo string, receivers []string, threshold int, storageTraceId string) *Transaction { 194 | if _, err := uuid.FromString(storageTraceId); err != nil { 195 | panic(err) 196 | } 197 | t := act.BuildTransaction(ctx, traceId, opponentAppId, assetId, amount, memo, receivers, threshold) 198 | t.storageTraceId = storageTraceId 199 | return t 200 | } 201 | 202 | func (act *Action) BuildStorageTransaction(ctx context.Context, extra []byte) *Transaction { 203 | if len(extra) > common.ExtraSizeStorageCapacity { 204 | panic(fmt.Errorf("too large extra %d > %d", len(extra), common.ExtraSizeStorageCapacity)) 205 | } 206 | 207 | sTraceId := crypto.Blake3Hash(extra).String() 208 | sTraceId = UniqueId(sTraceId, sTraceId) 209 | addr := common.NewAddressFromSeed(make([]byte, 64)) 210 | receivers := []string{addr.String()} 211 | amount := getStorageTransactionAmount(extra) 212 | t := act.BuildTransaction(ctx, sTraceId, act.group.GroupId, StorageAssetId, amount.String(), string(extra), receivers, 64) 213 | t.storage = true 214 | return t 215 | } 216 | 217 | func (act *Action) BuildWithdrawTransaction(ctx context.Context, traceId, assetId, amount, memo, destination, tag string) *Transaction { 218 | tx := &Transaction{ 219 | TraceId: traceId, 220 | AppId: act.AppId, 221 | OpponentAppId: act.group.GroupId, 222 | ActionId: act.OutputId, 223 | State: TransactionStateInitial, 224 | AssetId: assetId, 225 | Amount: amount, 226 | Memo: memo, 227 | Sequence: act.Sequence, 228 | Destination: sql.NullString{Valid: true, String: destination}, 229 | Tag: sql.NullString{Valid: true, String: tag}, 230 | WithdrawalHash: sql.NullString{Valid: false}, 231 | } 232 | outputs := act.group.ListOutputsForAsset(ctx, tx.AppId, tx.AssetId, act.consumed[assetId], tx.Sequence, SafeUtxoStateUnspent, OutputsBatchSize) 233 | if len(outputs) == 0 { 234 | panic(tx.TraceId) 235 | } 236 | inputs, _, err := act.group.getTransactionInputsAndRecipients(ctx, tx, outputs) 237 | if err != nil { 238 | panic(err) 239 | } 240 | tx.consumed = inputs 241 | for _, o := range tx.consumed { 242 | tx.consumedIds = append(tx.consumedIds, o.OutputId) 243 | if o.Sequence > act.consumed[assetId] { 244 | act.consumed[assetId] = o.Sequence 245 | } 246 | } 247 | return tx 248 | } 249 | 250 | func getStorageTransactionAmount(extra []byte) common.Integer { 251 | step := common.NewIntegerFromString(common.ExtraStoragePriceStep) 252 | return step.Mul(len(extra)/common.ExtraSizeStorageStep + 1) 253 | } 254 | 255 | func (t *Transaction) getConsumedString() string { 256 | if len(t.consumedIds) == 0 { 257 | panic(t.TraceId) 258 | } 259 | if len(t.consumed) > 0 && len(t.consumed) != len(t.consumedIds) { 260 | panic(t.TraceId) 261 | } 262 | return strings.Join(t.consumedIds, ",") 263 | } 264 | 265 | func (t *Transaction) Equal(tx *Transaction) bool { 266 | return tx.TraceId == t.TraceId && 267 | uuid.FromStringOrNil(tx.AppId).String() == uuid.FromStringOrNil(t.AppId).String() && 268 | tx.OpponentAppId == t.OpponentAppId && 269 | tx.State == t.State && 270 | tx.AssetId == t.AssetId && 271 | tx.Threshold == t.Threshold && 272 | tx.ActionId == t.ActionId && 273 | tx.Amount == t.Amount && 274 | tx.Memo == t.Memo && 275 | tx.Hash == t.Hash && 276 | tx.Sequence == t.Sequence && 277 | tx.compaction == t.compaction && 278 | tx.storage == t.storage && 279 | tx.storageTraceId == t.storageTraceId && 280 | tx.getConsumedString() == t.getConsumedString() && 281 | tx.Destination.String == t.Destination.String && 282 | tx.Tag.String == t.Tag.String && 283 | tx.WithdrawalHash.String == t.WithdrawalHash.String && 284 | bytes.Equal(tx.Raw, t.Raw) && 285 | slices.Equal(tx.Receivers, t.Receivers) && 286 | slices.Equal(tx.references, t.references) 287 | } 288 | 289 | func (t *Transaction) check(_ context.Context, act *Action) error { 290 | logger.Debugf("Group.checkTransaction(%v)\n", t) 291 | if _, err := uuid.FromString(t.AppId); err != nil { 292 | panic(err) 293 | } 294 | if t.AppId != act.AppId || t.Sequence != act.Sequence || t.ActionId != act.OutputId { 295 | return fmt.Errorf("invalid action origin: %s %d %s", t.AppId, t.Sequence, t.ActionId) 296 | } 297 | if len(t.references) > 2 { 298 | return fmt.Errorf("invalid references length: %d", len(t.references)) 299 | } 300 | for i, r := range t.references { 301 | if !r.HasValue() { 302 | return fmt.Errorf("invalid reference: %d %v", i, r) 303 | } 304 | } 305 | if !t.IsWithdrawal() { 306 | if t.Threshold < 1 || t.Threshold > 128 { 307 | return fmt.Errorf("invalid receivers threshold %d/%d", t.Threshold, len(t.Receivers)) 308 | } 309 | for _, r := range t.Receivers { 310 | id, _ := uuid.FromString(r) 311 | if id.String() == uuid.Nil.String() { 312 | _, err := mixinnet.AddressFromString(r) 313 | if err != nil { 314 | return fmt.Errorf("invalid receiver %s", r) 315 | } 316 | } 317 | } 318 | } 319 | amt := decimal.RequireFromString(t.Amount) 320 | min := decimal.RequireFromString("0.00000001") 321 | if amt.Cmp(min) < 0 { 322 | return fmt.Errorf("invalid amount %s", t.Amount) 323 | } 324 | 325 | limit := common.ExtraSizeGeneralLimit 326 | if t.IsStorage() { 327 | limit = common.ExtraSizeStorageCapacity 328 | } 329 | s := encodeMixinExtra(t.OpponentAppId, []byte(t.Memo)) 330 | 331 | if len(s) >= limit { 332 | return fmt.Errorf("invalid extra length: %d", len(s)) 333 | } 334 | 335 | encoded := t.Serialize() 336 | decoded, _ := Deserialize(encoded) 337 | if !t.Equal(decoded) { 338 | panic(hex.EncodeToString(encoded)) 339 | } 340 | return nil 341 | } 342 | 343 | func (grp *Group) buildCompactionTransaction(ctx context.Context, asset string, act *Action) (*Transaction, error) { 344 | // compaction transaction is special, this is the sole transaction for an action 345 | compaction := grp.ListOutputsForAsset(ctx, act.AppId, asset, act.consumed[asset], act.Sequence, SafeUtxoStateUnspent, OutputsBatchSize) 346 | if len(compaction) != OutputsBatchSize { 347 | return nil, fmt.Errorf("insufficient outputs to build compaction transaction: %d", len(compaction)) 348 | } 349 | total := decimal.NewFromInt(0) 350 | for _, out := range compaction { 351 | total = total.Add(out.Amount) 352 | } 353 | 354 | hash, err := crypto.HashFromString(act.TransactionHash) 355 | if err != nil { 356 | return nil, err 357 | } 358 | 359 | traceId := UniqueId(act.OutputId, "compaction") 360 | tx := act.BuildTransaction(ctx, traceId, act.AppId, asset, total.String(), "", grp.GetMembers(), grp.GetThreshold()) 361 | tx.references = []crypto.Hash{hash} 362 | tx.compaction = true 363 | return tx, nil 364 | } 365 | 366 | func (grp *Group) signTransaction(ctx context.Context, tx *Transaction) *common.VersionedTransaction { 367 | logger.Printf("Group.signTransaction(%v)\n", tx) 368 | 369 | txs, err := grp.store.ListPreviousInitialTransactions(ctx, tx.AssetId, tx.Sequence) 370 | logger.Verbosef("store.ListPreviousInitialTransactions(%s %d) => %d %v\n", tx.AssetId, tx.Sequence, len(txs), err) 371 | if err != nil { 372 | panic(err) 373 | } 374 | if len(txs) > 0 { 375 | return nil 376 | } 377 | 378 | if tx.storageTraceId != "" { 379 | storageTx, err := grp.store.ReadTransactionByTraceId(ctx, tx.storageTraceId) 380 | if err != nil || storageTx == nil || !storageTx.storage { 381 | panic(fmt.Errorf("store.ReadTransactionByTraceId(%s) => %v %v", tx.storageTraceId, storageTx, err)) 382 | } 383 | if storageTx.State != TransactionStateSnapshot { 384 | return nil 385 | } 386 | t, err := grp.readTransactionUntilSufficient(ctx, tx.storageTraceId) 387 | if err != nil { 388 | panic(err) 389 | } 390 | if storageTx.Hash.String() != t.TransactionHash { 391 | panic(tx.TraceId) 392 | } 393 | tx.references = []crypto.Hash{storageTx.Hash} 394 | } 395 | 396 | outputs := grp.ListOutputsForTransaction(ctx, tx.TraceId, tx.Sequence) 397 | logger.Verbosef("Group.ListOutputsForTransaction(%s) => %d %v\n", tx.TraceId, len(outputs), err) 398 | if len(outputs) == 0 { 399 | panic(fmt.Errorf("empty outputs %s", tx.Amount)) 400 | } 401 | if tx.compaction && len(outputs) < OutputsBatchSize { 402 | panic(fmt.Errorf("insufficient compaction transaction outputs %v %d", tx, len(outputs))) 403 | } 404 | 405 | ver, consumed, err := grp.buildRawTransaction(ctx, tx, outputs) 406 | logger.Verbosef("Group.buildRawTransaction(%v) => %v %d %v\n", tx, ver, len(consumed), err) 407 | if err != nil || len(outputs) != len(consumed) { 408 | panic(err) 409 | } 410 | if tx.compaction && len(ver.Outputs) != 1 { 411 | panic(fmt.Errorf("invalid compaction transaction %v", tx)) 412 | } 413 | 414 | raw := hex.EncodeToString(ver.Marshal()) 415 | req, err := grp.createMultisigUntilSufficient(ctx, tx.RequestID(), raw) 416 | if err != nil { 417 | panic(err) 418 | } 419 | if len(req.Signers) < int(req.SendersThreshold) && len(req.Views) > 0 { 420 | req, err = grp.signMultisigUntilSufficient(ctx, req) 421 | if err != nil { 422 | panic(err) 423 | } 424 | } else { 425 | rb, err := hex.DecodeString(req.RawTransaction) 426 | if err != nil { 427 | panic(err) 428 | } 429 | ver, err := common.UnmarshalVersionedTransaction(rb) 430 | if err != nil { 431 | panic(err) 432 | } 433 | if !CheckTestEnvironment(ctx) { 434 | if len(ver.SignaturesMap) != len(ver.Inputs) { 435 | panic(tx.TraceId) 436 | } 437 | for _, signatureMap := range ver.SignaturesMap { 438 | if len(signatureMap) < int(req.SendersThreshold) { 439 | panic(fmt.Errorf("invalid multisigs raw transaction: %s", req.RequestID)) 440 | } 441 | } 442 | } 443 | } 444 | 445 | vn, err := grp.updateTxWithOutputs(ctx, tx, consumed, req) 446 | if err != nil { 447 | panic(err) 448 | } 449 | if vn.PayloadHash() != ver.PayloadHash() { 450 | panic(vn.PayloadHash().String()) 451 | } 452 | return vn 453 | } 454 | 455 | func (grp *Group) updateTxWithOutputs(ctx context.Context, tx *Transaction, outputs []*UnifiedOutput, req *mixin.SafeMultisigRequest) (*common.VersionedTransaction, error) { 456 | for _, out := range outputs { 457 | out.TraceId = tx.TraceId 458 | out.State = SafeUtxoStateSigned 459 | out.SignedBy = req.TransactionHash 460 | } 461 | 462 | rb, err := hex.DecodeString(req.RawTransaction) 463 | if err != nil { 464 | return nil, err 465 | } 466 | ver, _ := common.UnmarshalVersionedTransaction(rb) 467 | tx.Raw = rb 468 | tx.Hash = ver.PayloadHash() 469 | tx.UpdatedAt = time.Now().UTC() 470 | tx.State = TransactionStateSigned 471 | tx.requestId = sql.NullString{Valid: true, String: req.RequestID} 472 | 473 | if tx.Hash.String() != req.TransactionHash { 474 | panic(req.TransactionHash) 475 | } 476 | if tx.IsNormal() { 477 | aid, _ := DecodeMixinExtraBase64(string(ver.Extra)) 478 | if aid != tx.OpponentAppId { 479 | panic(hex.EncodeToString(rb)) 480 | } 481 | } 482 | 483 | err = grp.store.UpdateTxWithOutputs(ctx, tx, outputs) 484 | if err != nil { 485 | panic(err) 486 | } 487 | return ver, nil 488 | } 489 | 490 | func (tx *Transaction) RequestID() string { 491 | if tx.requestId.Valid { 492 | return tx.requestId.String 493 | } 494 | return tx.TraceId 495 | } 496 | 497 | func (grp *Group) createMultisigUntilSufficient(ctx context.Context, id, raw string) (*mixin.SafeMultisigRequest, error) { 498 | if CheckTestEnvironment(ctx) { 499 | rb, _ := hex.DecodeString(raw) 500 | ver, _ := common.UnmarshalVersionedTransaction(rb) 501 | hash := ver.PayloadHash() 502 | return &mixin.SafeMultisigRequest{ 503 | RequestID: id, 504 | RawTransaction: raw, 505 | TransactionHash: hash.String(), 506 | }, nil 507 | } 508 | for { 509 | req, err := grp.mixin.SafeCreateMultisigRequest(ctx, &mixin.SafeTransactionRequestInput{ 510 | RequestID: id, 511 | RawTransaction: raw, 512 | }) 513 | logger.Verbosef("Group.SafeCreateTransactionRequest(%s, %s) => %v %v\n", id, raw, req, err) 514 | if err != nil && CheckRetryableError(err) { 515 | time.Sleep(3 * time.Second) 516 | continue 517 | } 518 | if err != nil { 519 | return nil, err 520 | } 521 | if req.RevokedBy != "" { 522 | id = UniqueId(id, "next") 523 | continue 524 | } 525 | return req, nil 526 | } 527 | } 528 | 529 | func (grp *Group) signMultisigUntilSufficient(ctx context.Context, input *mixin.SafeMultisigRequest) (*mixin.SafeMultisigRequest, error) { 530 | if CheckTestEnvironment(ctx) { 531 | rb, _ := hex.DecodeString(input.RawTransaction) 532 | ver, _ := common.UnmarshalVersionedTransaction(rb) 533 | hash := ver.PayloadHash() 534 | return &mixin.SafeMultisigRequest{ 535 | RequestID: input.RequestID, 536 | RawTransaction: input.RawTransaction, 537 | TransactionHash: hash.String(), 538 | Signers: []string{grp.GroupId}, 539 | }, nil 540 | } 541 | spendPublicKey, err := grp.getSpendPublicKeyUntilSufficient(ctx) 542 | if err != nil { 543 | return nil, err 544 | } 545 | key, err := mixinnet.ParseKeyWithPub(grp.spendPrivateKey, spendPublicKey) 546 | if err != nil { 547 | return nil, err 548 | } 549 | ver, err := mixinnet.TransactionFromRaw(input.RawTransaction) 550 | if err != nil { 551 | return nil, err 552 | } 553 | err = mixin.SafeSignTransaction(ver, key, input.Views, uint16(grp.Index())) 554 | if err != nil { 555 | return nil, err 556 | } 557 | signedRaw, err := ver.Dump() 558 | if err != nil { 559 | return nil, err 560 | } 561 | for { 562 | req, err := grp.mixin.SafeSignMultisigRequest(ctx, &mixin.SafeTransactionRequestInput{ 563 | RequestID: input.RequestID, 564 | RawTransaction: signedRaw, 565 | }) 566 | logger.Verbosef("Group.SafeSignMultisigRequest(%s %s) => %v %v\n", input.RequestID, signedRaw, req, err) 567 | if err != nil && CheckRetryableError(err) { 568 | time.Sleep(3 * time.Second) 569 | continue 570 | } 571 | return req, err 572 | } 573 | } 574 | 575 | func (grp *Group) buildRawTransaction(ctx context.Context, tx *Transaction, outputs []*UnifiedOutput) (*common.VersionedTransaction, []*UnifiedOutput, error) { 576 | inputs, tr, err := grp.getTransactionInputsAndRecipients(ctx, tx, outputs) 577 | if err != nil { 578 | return nil, nil, err 579 | } 580 | 581 | ver := common.NewTransactionV5(crypto.Sha256Hash([]byte(tx.AssetId))) 582 | for _, in := range inputs { 583 | h, err := crypto.HashFromString(in.TransactionHash) 584 | if err != nil { 585 | panic(in.TransactionHash) 586 | } 587 | ver.AddInput(h, uint(in.OutputIndex)) 588 | } 589 | 590 | keys, err := grp.createGhostKeysUntilSufficient(ctx, tx, tr) 591 | if err != nil { 592 | return nil, nil, err 593 | } 594 | for i, r := range tr { 595 | if r.Destination == "" && r.MixAddress == nil { 596 | panic(r) 597 | } 598 | if r.Destination != "" { 599 | ver.Outputs = append(ver.Outputs, &common.Output{ 600 | Type: common.OutputTypeWithdrawalSubmit, 601 | Amount: common.NewIntegerFromString(r.Amount), 602 | Withdrawal: &common.WithdrawalData{ 603 | Address: r.Destination, 604 | Tag: r.Tag, 605 | }, 606 | }) 607 | continue 608 | } 609 | 610 | ver.Outputs = append(ver.Outputs, newCommonOutput(&mixinnet.Output{ 611 | Type: common.OutputTypeScript, 612 | Mask: keys[i].Mask, 613 | Keys: keys[i].Keys, 614 | Amount: mixinnet.IntegerFromString(r.Amount), 615 | Script: mixinnet.NewThresholdScript(uint8(r.MixAddress.Threshold)), 616 | })) 617 | } 618 | 619 | ver.References = tx.references 620 | ver.Extra = []byte(tx.Memo) 621 | if tx.IsNormal() { 622 | ver.Extra = []byte(EncodeMixinExtraBase64(tx.OpponentAppId, ver.Extra)) 623 | } 624 | 625 | if l := ver.AsVersioned().GetExtraLimit(); len(ver.Extra) >= l { 626 | return nil, nil, fmt.Errorf("large extra %d > %d", len(ver.Extra), l) 627 | } 628 | return ver.AsVersioned(), inputs, nil 629 | } 630 | -------------------------------------------------------------------------------- /mtg/utils.go: -------------------------------------------------------------------------------- 1 | package mtg 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "encoding/hex" 8 | "encoding/json" 9 | "fmt" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/MixinNetwork/mixin/common" 15 | "github.com/MixinNetwork/mixin/crypto" 16 | "github.com/MixinNetwork/mixin/logger" 17 | "github.com/fox-one/mixin-sdk-go/v2" 18 | "github.com/fox-one/mixin-sdk-go/v2/mixinnet" 19 | "github.com/gofrs/uuid/v5" 20 | "github.com/shopspring/decimal" 21 | ) 22 | 23 | type contextKeyTyp string 24 | 25 | const ( 26 | contextKeyEnvironment = contextKeyTyp("environment") 27 | ) 28 | 29 | func EnableTestEnvironment(ctx context.Context) context.Context { 30 | return context.WithValue(ctx, contextKeyEnvironment, "test") 31 | } 32 | 33 | func CheckTestEnvironment(ctx context.Context) bool { 34 | val := ctx.Value(contextKeyEnvironment) 35 | if val == nil { 36 | return false 37 | } 38 | env, ok := val.(string) 39 | return ok && env == "test" 40 | } 41 | 42 | func UniqueId(a, b string) string { 43 | return mixin.UniqueConversationID(a, b) 44 | } 45 | 46 | func SplitIds(s string) []string { 47 | if strings.TrimSpace(s) != s { 48 | panic(s) 49 | } 50 | if s == "" { 51 | return make([]string, 0) 52 | } 53 | a := strings.Split(s, ",") 54 | for _, e := range a { 55 | if strings.TrimSpace(e) == "" { 56 | panic(s) 57 | } 58 | } 59 | return a 60 | } 61 | 62 | func CheckRetryableError(err error) bool { 63 | if err == nil { 64 | return false 65 | } 66 | es := err.Error() 67 | switch { 68 | case strings.Contains(es, "Client.Timeout exceeded"): 69 | case strings.Contains(es, "Bad Gateway"): 70 | case strings.Contains(es, "Internal Server Error"): 71 | case strings.Contains(es, "invalid character '<' looking for beginning of value"): 72 | default: 73 | return false 74 | } 75 | return true 76 | } 77 | 78 | func NewMixAddress(ctx context.Context, members []string, threshold byte) (*mixin.MixAddress, bool, error) { 79 | if len(members) == 0 || threshold == 0 { 80 | panic(len(members)) 81 | } 82 | if CheckTestEnvironment(ctx) { 83 | for i, m := range members { 84 | _, err := mixinnet.AddressFromString(m) 85 | if err == nil { 86 | break 87 | } 88 | members[i] = UniqueId(m, m) 89 | } 90 | } 91 | 92 | isUuidMembers := false 93 | ma, err := mixin.NewMainnetMixAddress(members, 1) 94 | if err != nil { 95 | ma, err = mixin.NewMixAddress(members, 1) 96 | if err != nil { 97 | return nil, false, err 98 | } 99 | isUuidMembers = true 100 | } 101 | ma.Threshold = threshold 102 | return ma, isUuidMembers, nil 103 | } 104 | 105 | func DecodeMixinExtraHEX(memo string) (string, []byte) { 106 | extra, err := hex.DecodeString(memo) 107 | if err != nil { 108 | return "", nil 109 | } 110 | return DecodeMixinExtraBase64(string(extra)) 111 | } 112 | 113 | func DecodeMixinExtraBase64(extra string) (string, []byte) { 114 | data, err := base64.RawURLEncoding.DecodeString(extra) 115 | if err != nil || len(data) < 16 { 116 | return "", nil 117 | } 118 | aid := uuid.FromBytesOrNil(data[0:16]) 119 | return aid.String(), data[16:] 120 | } 121 | 122 | func encodeMixinExtra(appId string, extra []byte) string { 123 | gid, err := uuid.FromString(appId) 124 | if err != nil { 125 | panic(err) 126 | } 127 | data := gid.Bytes() 128 | data = append(data, extra...) 129 | s := base64.RawURLEncoding.EncodeToString(data) 130 | return s 131 | } 132 | 133 | func EncodeMixinExtraBase64(appId string, extra []byte) string { 134 | s := encodeMixinExtra(appId, extra) 135 | if len(s) >= common.ExtraSizeGeneralLimit { 136 | panic(len(extra)) 137 | } 138 | return s 139 | } 140 | 141 | func newCommonOutput(out *mixinnet.Output) *common.Output { 142 | cout := &common.Output{ 143 | Type: common.OutputTypeScript, 144 | Amount: common.NewIntegerFromString(out.Amount.String()), 145 | Script: common.Script(out.Script), 146 | Mask: crypto.Key(out.Mask), 147 | } 148 | for _, k := range out.Keys { 149 | ck := crypto.Key(k) 150 | cout.Keys = append(cout.Keys, &ck) 151 | } 152 | return cout 153 | } 154 | 155 | func (grp *Group) getSpendPublicKeyUntilSufficient(ctx context.Context) (string, error) { 156 | for { 157 | me, err := grp.mixin.UserMe(ctx) 158 | logger.Verbosef("Group.UserMe() => %v\n", err) 159 | if CheckRetryableError(err) { 160 | time.Sleep(3 * time.Second) 161 | continue 162 | } 163 | return me.SpendPublicKey, err 164 | } 165 | } 166 | 167 | func (grp *Group) ReadKernelTransactionUntilSufficient(ctx context.Context, txHash string) (*common.VersionedTransaction, error) { 168 | key := fmt.Sprintf("readKernelTransactionUntilSufficient(%s)", txHash) 169 | val, err := grp.store.ReadCache(ctx, key) 170 | if err != nil { 171 | panic(err) 172 | } 173 | if val != "" { 174 | b, err := base64.RawURLEncoding.DecodeString(val) 175 | if err != nil { 176 | panic(err) 177 | } 178 | ver, err := common.UnmarshalVersionedTransaction(b) 179 | if err != nil { 180 | panic(err) 181 | } 182 | return ver, nil 183 | } 184 | ver, err := grp.readKernelTransactionUntilSufficientImpl(ctx, txHash) 185 | if err != nil { 186 | return nil, err 187 | } 188 | val = base64.RawURLEncoding.EncodeToString(ver.Marshal()) 189 | err = grp.store.WriteCache(ctx, key, val) 190 | if err != nil { 191 | panic(err) 192 | } 193 | return ver, nil 194 | } 195 | 196 | func (grp *Group) readKernelTransactionUntilSufficientImpl(ctx context.Context, txHash string) (*common.VersionedTransaction, error) { 197 | if CheckTestEnvironment(ctx) { 198 | hash, err := crypto.HashFromString(txHash) 199 | if err != nil { 200 | return nil, err 201 | } 202 | tx, err := grp.store.ReadTransactionByHash(ctx, hash) 203 | if err != nil { 204 | return nil, err 205 | } 206 | if tx == nil { 207 | ver := common.NewTransactionV5(common.XINAssetId).AsVersioned() 208 | return ver, nil 209 | } 210 | ver, err := common.UnmarshalVersionedTransaction(tx.Raw) 211 | return ver, err 212 | } 213 | for { 214 | ver, snapshot, err := GetKernelTransaction(grp.kernelRPC, txHash) 215 | if CheckRetryableError(err) || snapshot == "" { 216 | time.Sleep(time.Second) 217 | continue 218 | } 219 | return ver, err 220 | } 221 | } 222 | 223 | func (grp *Group) readTransactionUntilSufficient(ctx context.Context, id string) (*SafeTransactionRequest, error) { 224 | key := fmt.Sprintf("readTransactionUntilSufficient(%s)", id) 225 | val, err := grp.store.ReadCache(ctx, key) 226 | if err != nil { 227 | panic(err) 228 | } 229 | if val != "" { 230 | var r SafeTransactionRequest 231 | err = json.Unmarshal([]byte(val), &r) 232 | if err != nil { 233 | panic(err) 234 | } 235 | return &r, nil 236 | } 237 | r, err := grp.readTransactionUntilSufficientImpl(ctx, id) 238 | if err != nil || r == nil || r.State != SafeUtxoStateSpent { 239 | return r, err 240 | } 241 | if r.Receivers[0].Destination != "" && r.Receivers[0].WithdrawalHash == "" { 242 | return r, nil 243 | } 244 | b, err := json.Marshal(r) 245 | if err != nil { 246 | panic(err) 247 | } 248 | err = grp.store.WriteCache(ctx, key, string(b)) 249 | if err != nil { 250 | panic(err) 251 | } 252 | return r, nil 253 | } 254 | 255 | type SafeTransactionReceiver struct { 256 | Members []string `json:"members,omitempty"` 257 | MemberHash string `json:"members_hash,omitempty"` 258 | Threshold uint8 `json:"threshold,omitempty"` 259 | Destination string `json:"destination,omitempty"` 260 | Tag string `json:"Tag,omitempty"` 261 | WithdrawalHash string `json:"withdrawal_hash,omitempty"` 262 | } 263 | 264 | type SafeTransactionRequest struct { 265 | RequestID string `json:"request_id,omitempty"` 266 | TransactionHash string `json:"transaction_hash,omitempty"` 267 | UserID string `json:"user_id,omitempty"` 268 | KernelAssetID mixinnet.Hash `json:"kernel_asset_id,omitempty"` 269 | AssetID mixinnet.Hash `json:"asset_id,omitempty"` 270 | Amount decimal.Decimal `json:"amount,omitempty"` 271 | CreatedAt time.Time `json:"created_at,omitempty"` 272 | UpdatedAt time.Time `json:"updated_at,omitempty"` 273 | Extra string `json:"extra,omitempty"` 274 | Receivers []*SafeTransactionReceiver `json:"receivers,omitempty"` 275 | Senders []string `json:"senders,omitempty"` 276 | SendersHash string `json:"senders_hash,omitempty"` 277 | SendersThreshold uint8 `json:"senders_threshold,omitempty"` 278 | Signers []string `json:"signers,omitempty"` 279 | SnapshotHash string `json:"snapshot_hash,omitempty"` 280 | SnapshotAt *time.Time `json:"snapshot_at,omitempty"` 281 | State SafeUtxoState `json:"state,omitempty"` 282 | RawTransaction string `json:"raw_transaction"` 283 | Views []mixinnet.Key `json:"views,omitempty"` 284 | RevokedBy string `json:"revoked_by"` 285 | 286 | Asset mixinnet.Hash `json:"asset,omitempty"` 287 | } 288 | 289 | func (grp *Group) readTransactionUntilSufficientImpl(ctx context.Context, id string) (*SafeTransactionRequest, error) { 290 | if CheckTestEnvironment(ctx) { 291 | tx, err := grp.store.ReadTransactionByTraceId(ctx, id) 292 | if err != nil { 293 | return nil, err 294 | } 295 | return &SafeTransactionRequest{ 296 | RequestID: tx.TraceId, 297 | RawTransaction: hex.EncodeToString(tx.Raw), 298 | TransactionHash: tx.Hash.String(), 299 | Signers: []string{grp.GroupId}, 300 | }, nil 301 | } 302 | for { 303 | var req SafeTransactionRequest 304 | err := grp.mixin.Get(ctx, "/safe/transactions/"+id, nil, &req) 305 | logger.Verbosef("Group.SafeReadTransactionRequest(%s) => %v %v\n", id, req, err) 306 | if err == nil { 307 | return &req, nil 308 | } 309 | if CheckRetryableError(err) { 310 | time.Sleep(time.Second) 311 | continue 312 | } 313 | if strings.Contains(err.Error(), "not found") { 314 | return nil, nil 315 | } 316 | return nil, err 317 | } 318 | } 319 | 320 | func (grp *Group) getTransactionInputsAndRecipients(ctx context.Context, tx *Transaction, outputs []*UnifiedOutput) ([]*UnifiedOutput, []*TransactionRecipient, error) { 321 | var tr []*TransactionRecipient 322 | if tx.IsWithdrawal() { 323 | tr = []*TransactionRecipient{{ 324 | Amount: tx.Amount, 325 | Destination: tx.Destination.String, 326 | }} 327 | if tx.Tag.Valid { 328 | tr[0].Tag = tx.Tag.String 329 | } 330 | } else { 331 | ma, uuidMember, err := NewMixAddress(ctx, tx.Receivers, byte(tx.Threshold)) 332 | if err != nil { 333 | return nil, nil, err 334 | } 335 | tr = []*TransactionRecipient{{ 336 | MixAddress: ma, 337 | Amount: tx.Amount, 338 | UuidMember: uuidMember, 339 | }} 340 | } 341 | 342 | target := common.NewIntegerFromString(tx.Amount) 343 | var total common.Integer 344 | var consumed []*UnifiedOutput 345 | for _, out := range outputs { 346 | total = total.Add(common.NewIntegerFromString(out.Amount.String())) 347 | consumed = append(consumed, out) 348 | if total.Cmp(target) >= 0 && len(consumed) >= grp.groupSize { 349 | break 350 | } 351 | } 352 | 353 | change := total.Sub(target) 354 | if change.Sign() < 0 { 355 | return nil, nil, fmt.Errorf("insufficient %d %s %s", len(outputs), total, tx.Amount) 356 | } else if change.Sign() > 0 { 357 | ma, uuidMember, err := NewMixAddress(ctx, grp.GetMembers(), byte(grp.GetThreshold())) 358 | if err != nil { 359 | return nil, nil, err 360 | } 361 | tr = append(tr, &TransactionRecipient{ 362 | MixAddress: ma, 363 | Amount: change.String(), 364 | UuidMember: uuidMember, 365 | }) 366 | } 367 | return consumed, tr, nil 368 | } 369 | 370 | func (grp *Group) createGhostKeysUntilSufficient(ctx context.Context, tx *Transaction, tr []*TransactionRecipient) (map[int]*mixin.GhostKeys, error) { 371 | gkm := make(map[int]*mixin.GhostKeys, len(tr)) 372 | if CheckTestEnvironment(ctx) { 373 | if tx.TraceId == "cf0564ba-bf51-4e8c-b504-3beb6c5c65e3" { 374 | tr[1].MixAddress.Threshold = 2 375 | mask, _ := mixinnet.KeyFromString("f18e0e276648b1d42063f8bcf9d5a57252f4048c9939ded0999a0e263716976e") 376 | key1, _ := mixinnet.KeyFromString("f5c8b3dbb7a5b2f7e1e4640d9f61c142cda547917f227ba21ebc5d554651c50d") 377 | key2, _ := mixinnet.KeyFromString("18f71fbe1b5055f3d882a4ae2813fad315bf0dcb5a0e60f091121db882baff77") 378 | gkm[1] = &mixin.GhostKeys{ 379 | Mask: mask, 380 | Keys: []mixinnet.Key{key1, key2}, 381 | } 382 | return gkm, nil 383 | } 384 | key1, err := testGetGhostKeys(tx, 0) 385 | if err != nil { 386 | return nil, err 387 | } 388 | key2, err := testGetGhostKeys(tx, 1) 389 | if err != nil { 390 | return nil, err 391 | } 392 | gkm[0] = key1 393 | gkm[1] = key2 394 | return gkm, nil 395 | } 396 | 397 | var uuidGkrs []*mixin.GhostInput 398 | for i, r := range tr { 399 | if r.MixAddress == nil { 400 | continue 401 | } 402 | members := r.MixAddress.Members() 403 | if r.UuidMember { 404 | sort.Strings(members) 405 | hint := UniqueId(tx.TraceId, fmt.Sprintf("index:%d", i)) 406 | uuidGkrs = append(uuidGkrs, &mixin.GhostInput{ 407 | Receivers: members, 408 | Index: uint8(i), 409 | Hint: hint, 410 | }) 411 | } else { 412 | index := make([]byte, 16) 413 | binary.BigEndian.PutUint16(index, uint16(i)) 414 | 415 | seed := uuid.FromStringOrNil(tx.TraceId).Bytes() 416 | seed = append(seed, uuid.FromStringOrNil(tx.AssetId).Bytes()...) 417 | seed = append(seed, uuid.FromStringOrNil(tx.OpponentAppId).Bytes()...) 418 | seed = append(seed, index...) 419 | r := mixinnet.KeyFromBytes(seed) 420 | 421 | keys := make([]mixinnet.Key, len(members)) 422 | for i, a := range members { 423 | addr, err := mixinnet.AddressFromString(a) 424 | if err != nil { 425 | return nil, err 426 | } 427 | key := mixinnet.DeriveGhostPublicKey(mixinnet.TxVersionHashSignature, &r, &addr.PublicViewKey, &addr.PublicSpendKey, uint8(i)) 428 | keys[i] = *key 429 | } 430 | gkm[i] = &mixin.GhostKeys{ 431 | Mask: r.Public(), 432 | Keys: keys, 433 | } 434 | } 435 | } 436 | if len(uuidGkrs) == 0 { 437 | return gkm, nil 438 | } 439 | 440 | for { 441 | keys, err := grp.mixin.SafeCreateGhostKeys(ctx, uuidGkrs, grp.GetMembers()...) 442 | logger.Verbosef("Group.SafeCreateGhostKeys(%s) => %v %v\n", tx.TraceId, keys, err) 443 | if CheckRetryableError(err) { 444 | time.Sleep(3 * time.Second) 445 | continue 446 | } 447 | for i, g := range keys { 448 | index := uuidGkrs[i].Index 449 | gkm[int(index)] = g 450 | } 451 | return gkm, err 452 | } 453 | } 454 | 455 | func testGetGhostKeys(tx *Transaction, index int) (*mixin.GhostKeys, error) { 456 | k := &mixin.GhostKeys{} 457 | for _, r := range tx.Receivers { 458 | id := UniqueId(r, tx.TraceId) 459 | id = UniqueId(id, fmt.Sprint(index)) 460 | bs := uuid.FromStringOrNil(id).Bytes() 461 | bs = append(bs, bs...) 462 | key, err := mixinnet.KeyFromSeed(hex.EncodeToString(bs)) 463 | if err != nil { 464 | return nil, err 465 | } 466 | k.Keys = append(k.Keys, key) 467 | } 468 | 469 | id := UniqueId(tx.TraceId, fmt.Sprint(index)) 470 | bs := uuid.FromStringOrNil(id).Bytes() 471 | bs = append(bs, bs...) 472 | m, err := mixinnet.KeyFromSeed(hex.EncodeToString(bs)) 473 | if err != nil { 474 | return nil, err 475 | } 476 | k.Mask = m 477 | return k, nil 478 | } 479 | --------------------------------------------------------------------------------