├── .env
├── .gitignore
├── README.md
├── app.yaml
├── common
├── README.md
├── address.go
├── asset.go
├── decoding.go
├── deposit.go
├── domain.go
├── encoding.go
├── go.mod
├── go.sum
├── integer.go
├── mint.go
├── node.go
├── ration.go
├── script.go
├── transaction.go
├── transaction_js.go
├── utxo.go
├── validation.go
├── version.go
└── withdrawal.go
├── deploy.sh
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── robots.txt
└── transaction.js
├── src
├── App.css
├── App.js
├── App.test.js
├── api
│ ├── client.js
│ ├── index.js
│ ├── storage.js
│ └── util.js
├── asset
│ ├── contacts.js
│ ├── contacts.module.scss
│ ├── index.js
│ ├── index.module.scss
│ ├── modal.js
│ ├── modal.module.scss
│ └── view.js
├── auth
│ ├── index.js
│ └── view.js
├── components
│ ├── cover.js
│ ├── header.js
│ ├── index.module.scss
│ └── loading.js
├── guide
│ ├── index.js
│ ├── index.module.scss
│ └── view.js
├── home
│ ├── index.js
│ ├── index.module.scss
│ ├── modal.js
│ ├── modal.module.scss
│ ├── notfound.js
│ ├── notfound.module.scss
│ └── view.js
├── index.js
├── index.scss
├── locales
│ ├── en.js
│ └── index.js
├── logo.svg
├── reportWebVitals.js
├── setupTests.js
├── statics
│ └── images
│ │ ├── bg.png
│ │ ├── bg_texture.png
│ │ ├── ic_amount.svg
│ │ ├── ic_close.svg
│ │ ├── ic_guide.svg
│ │ ├── ic_left.svg
│ │ ├── ic_link.svg
│ │ ├── ic_right.svg
│ │ ├── ic_search.svg
│ │ ├── ic_select.svg
│ │ ├── ic_selected.svg
│ │ ├── ic_setting.svg
│ │ ├── ic_transaction.svg
│ │ ├── ic_wallet.svg
│ │ ├── loading_spin.svg
│ │ └── notfound.svg
└── transfer
│ ├── index.js
│ ├── index.module.scss
│ ├── show.js
│ ├── show.module.scss
│ ├── view.js
│ └── withdrawal.js
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_CLIENT_ID=3364e886-d2c9-496b-8b44-d9bc374dceae
2 | REACT_APP_SESSION_ID=3364e886-d2c9-496b-8b44-d9bc374dceae
3 | REACT_APP_PRIVATE_KEY=KPPRXvl-_MqsFS...yrm9yhnOfy0R4rN9RIndlm9TQK_mJtJqsfr5D9Px-eCDRP4mSA13k1cJDA
4 | REACT_APP_PIN_TOKEN=KPPRXvl-_MqsFMOUCnKzhKqRyrm9yhnOfy0R4rN9RIndl
5 | REACT_APP_PIN=123456
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | src/utils/common/common.js
26 | src/utils/common/common.js.map
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A client only bot to demonstrate the multisig feature of Mixin Messenger.
2 |
3 |
--------------------------------------------------------------------------------
/app.yaml:
--------------------------------------------------------------------------------
1 | runtime: python38
2 | service: multisig-wallet
3 |
4 | handlers:
5 | - url: /static
6 | static_dir: static
7 |
8 | - url: /favicon.ico
9 | static_files: favicon.ico
10 | upload: favicon.ico
11 |
12 | - url: /manifest.json
13 | static_files: manifest.json
14 | upload: manifest.json
15 |
16 | - url: /transaction.js
17 | static_files: transaction.js
18 | upload: transaction.js
19 |
20 | - url: /.*
21 | static_files: index.html
22 | upload: index.html
23 |
--------------------------------------------------------------------------------
/common/README.md:
--------------------------------------------------------------------------------
1 | 1. go get -u github.com/gopherjs/gopherjs
2 | 2. download go1.16.x and tar xvf in current dir
3 | 3. export GOPHERJS_GOROOT="$(./go/bin/go env GOROOT)"
4 | 4. ./go/bin/go get
5 | 5. MixinNetwork/mixin checkout to v0.12.1
6 | 6. gopherjs build
7 | 7. mv common.js ./src/utils/transaction.js
8 |
9 | For react
10 | add /* eslint-disable */ to transaction.js
11 |
--------------------------------------------------------------------------------
/common/address.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/MixinNetwork/mixin/crypto"
10 | "github.com/btcsuite/btcutil/base58"
11 | )
12 |
13 | const MainNetworkId = "XIN"
14 |
15 | type Address struct {
16 | PrivateSpendKey crypto.Key
17 | PrivateViewKey crypto.Key
18 | PublicSpendKey crypto.Key
19 | PublicViewKey crypto.Key
20 | }
21 |
22 | func NewAddressFromSeed(seed []byte) Address {
23 | hash1 := crypto.NewHash(seed)
24 | hash2 := crypto.NewHash(hash1[:])
25 | src := append(hash1[:], hash2[:]...)
26 | spend := crypto.NewKeyFromSeed(seed)
27 | view := crypto.NewKeyFromSeed(src)
28 |
29 | return Address{
30 | PrivateSpendKey: spend,
31 | PrivateViewKey: view,
32 | PublicSpendKey: spend.Public(),
33 | PublicViewKey: view.Public(),
34 | }
35 | }
36 |
37 | func NewAddressFromString(s string) (Address, error) {
38 | var a Address
39 | if !strings.HasPrefix(s, MainNetworkId) {
40 | return a, errors.New("invalid address network")
41 | }
42 | data := base58.Decode(s[len(MainNetworkId):])
43 | if len(data) != 68 {
44 | return a, errors.New("invalid address format")
45 | }
46 | checksum := crypto.NewHash(append([]byte(MainNetworkId), data[:64]...))
47 | if !bytes.Equal(checksum[:4], data[64:]) {
48 | return a, errors.New("invalid address checksum")
49 | }
50 | copy(a.PublicSpendKey[:], data[:32])
51 | copy(a.PublicViewKey[:], data[32:])
52 | return a, nil
53 | }
54 |
55 | func (a Address) String() string {
56 | data := append([]byte(MainNetworkId), a.PublicSpendKey[:]...)
57 | data = append(data, a.PublicViewKey[:]...)
58 | checksum := crypto.NewHash(data)
59 | data = append(a.PublicSpendKey[:], a.PublicViewKey[:]...)
60 | data = append(data, checksum[:4]...)
61 | return MainNetworkId + base58.Encode(data)
62 | }
63 |
64 | func (a Address) Hash() crypto.Hash {
65 | return crypto.NewHash(append(a.PublicSpendKey[:], a.PublicViewKey[:]...))
66 | }
67 |
68 | func (a Address) MarshalJSON() ([]byte, error) {
69 | return []byte(strconv.Quote(a.String())), nil
70 | }
71 |
72 | func (a *Address) UnmarshalJSON(b []byte) error {
73 | unquoted, err := strconv.Unquote(string(b))
74 | if err != nil {
75 | return err
76 | }
77 | m, err := NewAddressFromString(unquoted)
78 | if err != nil {
79 | return err
80 | }
81 | a.PrivateSpendKey = m.PrivateSpendKey
82 | a.PrivateViewKey = m.PrivateViewKey
83 | a.PublicSpendKey = m.PublicSpendKey
84 | a.PublicViewKey = m.PublicViewKey
85 | return nil
86 | }
87 |
--------------------------------------------------------------------------------
/common/asset.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/MixinNetwork/mixin/crypto"
5 | )
6 |
7 | var (
8 | XINAssetId crypto.Hash
9 | )
10 |
11 | type Asset struct {
12 | ChainId crypto.Hash
13 | AssetKey string
14 | }
15 |
16 | func init() {
17 | XINAssetId = crypto.NewHash([]byte("c94ac88f-4671-3976-b60a-09064f1811e8"))
18 | }
19 |
20 | func (a *Asset) Verify() error {
21 | return nil
22 | }
23 |
24 | func (a *Asset) AssetId() crypto.Hash {
25 | return crypto.Hash{}
26 | }
27 |
28 | func (a *Asset) FeeAssetId() crypto.Hash {
29 | return crypto.Hash{}
30 | }
31 |
--------------------------------------------------------------------------------
/common/decoding.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/binary"
6 | "fmt"
7 | "io"
8 |
9 | "github.com/MixinNetwork/mixin/crypto"
10 | )
11 |
12 | type Decoder struct {
13 | buf *bytes.Reader
14 | }
15 |
16 | func NewDecoder(b []byte) *Decoder {
17 | return &Decoder{buf: bytes.NewReader(b)}
18 | }
19 |
20 | func (dec *Decoder) DecodeTransaction() (*SignedTransaction, error) {
21 | b := make([]byte, 4)
22 | err := dec.Read(b)
23 | if err != nil {
24 | return nil, err
25 | }
26 | if !checkTxVersion(b) {
27 | return nil, fmt.Errorf("invalid version %v", b)
28 | }
29 |
30 | var tx SignedTransaction
31 | tx.Version = TxVersion
32 |
33 | err = dec.Read(tx.Asset[:])
34 | if err != nil {
35 | return nil, err
36 | }
37 |
38 | il, err := dec.ReadInt()
39 | if err != nil {
40 | return nil, err
41 | }
42 | for ; il > 0; il -= 1 {
43 | in, err := dec.ReadInput()
44 | if err != nil {
45 | return nil, err
46 | }
47 | tx.Inputs = append(tx.Inputs, in)
48 | }
49 |
50 | ol, err := dec.ReadInt()
51 | if err != nil {
52 | return nil, err
53 | }
54 | for ; ol > 0; ol -= 1 {
55 | o, err := dec.ReadOutput()
56 | if err != nil {
57 | return nil, err
58 | }
59 | tx.Outputs = append(tx.Outputs, o)
60 | }
61 |
62 | eb, err := dec.ReadBytes()
63 | if err != nil {
64 | return nil, err
65 | }
66 | tx.Extra = eb
67 |
68 | sl, err := dec.ReadInt()
69 | if err != nil {
70 | return nil, err
71 | }
72 | for ; sl > 0; sl -= 1 {
73 | sm, err := dec.ReadSignatures()
74 | if err != nil {
75 | return nil, err
76 | }
77 | tx.SignaturesMap = append(tx.SignaturesMap, sm)
78 | }
79 |
80 | es, err := dec.buf.ReadByte()
81 | if err != io.EOF || es != 0 {
82 | return nil, fmt.Errorf("unexpected ending %d %v", es, err)
83 | }
84 | return &tx, nil
85 | }
86 |
87 | func (dec *Decoder) ReadInput() (*Input, error) {
88 | var in Input
89 | err := dec.Read(in.Hash[:])
90 | if err != nil {
91 | return nil, err
92 | }
93 |
94 | ii, err := dec.ReadInt()
95 | if err != nil {
96 | return nil, err
97 | }
98 | in.Index = ii
99 |
100 | gb, err := dec.ReadBytes()
101 | if err != nil {
102 | return nil, err
103 | }
104 | in.Genesis = gb
105 |
106 | hd, err := dec.ReadMagic()
107 | if err != nil {
108 | return nil, err
109 | } else if hd {
110 | var d DepositData
111 | err = dec.Read(d.Chain[:])
112 | if err != nil {
113 | return nil, err
114 | }
115 |
116 | ak, err := dec.ReadBytes()
117 | if err != nil {
118 | return nil, err
119 | }
120 | d.AssetKey = string(ak)
121 |
122 | th, err := dec.ReadBytes()
123 | if err != nil {
124 | return nil, err
125 | }
126 | d.TransactionHash = string(th)
127 |
128 | oi, err := dec.ReadUint64()
129 | if err != nil {
130 | return nil, err
131 | }
132 | d.OutputIndex = oi
133 |
134 | amt, err := dec.ReadInteger()
135 | if err != nil {
136 | return nil, err
137 | }
138 | d.Amount = amt
139 | in.Deposit = &d
140 | }
141 |
142 | hm, err := dec.ReadMagic()
143 | if err != nil {
144 | return nil, err
145 | } else if hm {
146 | var m MintData
147 | gb, err := dec.ReadBytes()
148 | if err != nil {
149 | return nil, err
150 | }
151 | m.Group = string(gb)
152 |
153 | bi, err := dec.ReadUint64()
154 | if err != nil {
155 | return nil, err
156 | }
157 | m.Batch = bi
158 |
159 | amt, err := dec.ReadInteger()
160 | if err != nil {
161 | return nil, err
162 | }
163 | m.Amount = amt
164 | in.Mint = &m
165 | }
166 |
167 | return &in, nil
168 | }
169 |
170 | func (dec *Decoder) ReadOutput() (*Output, error) {
171 | var o Output
172 |
173 | var t [2]byte
174 | err := dec.Read(t[:])
175 | if err != nil {
176 | return nil, err
177 | }
178 | if t[0] != 0 {
179 | return nil, fmt.Errorf("invalid output type %v", t)
180 | }
181 | o.Type = t[1]
182 |
183 | amt, err := dec.ReadInteger()
184 | if err != nil {
185 | return nil, err
186 | }
187 | o.Amount = amt
188 |
189 | kc, err := dec.ReadInt()
190 | if err != nil {
191 | return nil, err
192 | }
193 | for ; kc > 0; kc -= 1 {
194 | var k crypto.Key
195 | err := dec.Read(k[:])
196 | if err != nil {
197 | return nil, err
198 | }
199 | o.Keys = append(o.Keys, k)
200 | }
201 |
202 | err = dec.Read(o.Mask[:])
203 | if err != nil {
204 | return nil, err
205 | }
206 |
207 | sb, err := dec.ReadBytes()
208 | if err != nil {
209 | return nil, err
210 | }
211 | o.Script = sb
212 |
213 | hw, err := dec.ReadMagic()
214 | if err != nil {
215 | return nil, err
216 | } else if hw {
217 | var w WithdrawalData
218 | err := dec.Read(w.Chain[:])
219 | if err != nil {
220 | return nil, err
221 | }
222 |
223 | ak, err := dec.ReadBytes()
224 | if err != nil {
225 | return nil, err
226 | }
227 | w.AssetKey = string(ak)
228 |
229 | ab, err := dec.ReadBytes()
230 | if err != nil {
231 | return nil, err
232 | }
233 | w.Address = string(ab)
234 |
235 | tb, err := dec.ReadBytes()
236 | if err != nil {
237 | return nil, err
238 | }
239 | w.Address = string(tb)
240 |
241 | o.Withdrawal = &w
242 | }
243 |
244 | return &o, nil
245 | }
246 |
247 | func (dec *Decoder) ReadSignatures() (map[uint16]*crypto.Signature, error) {
248 | sc, err := dec.ReadInt()
249 | if err != nil {
250 | return nil, err
251 | }
252 |
253 | sm := make(map[uint16]*crypto.Signature)
254 | for i := 0; i < sc; i += 1 {
255 | si, err := dec.ReadUint16()
256 | if err != nil {
257 | return nil, err
258 | }
259 | var sig crypto.Signature
260 | err = dec.Read(sig[:])
261 | if err != nil {
262 | return nil, err
263 | }
264 | sm[si] = &sig
265 | }
266 |
267 | if len(sm) != sc {
268 | return nil, fmt.Errorf("signatures count %d %v", sc, sm)
269 | }
270 | return sm, nil
271 | }
272 |
273 | func (dec *Decoder) Read(b []byte) error {
274 | l, err := dec.buf.Read(b)
275 | if err != nil {
276 | return err
277 | }
278 | if l != len(b) {
279 | return fmt.Errorf("data short %d %d", l, len(b))
280 | }
281 | return nil
282 | }
283 |
284 | func (dec *Decoder) ReadInt() (int, error) {
285 | var b [2]byte
286 | err := dec.Read(b[:])
287 | if err != nil {
288 | return 0, err
289 | }
290 | d := binary.BigEndian.Uint16(b[:])
291 | if d > 256 {
292 | return 0, fmt.Errorf("large int %d", d)
293 | }
294 | return int(d), nil
295 | }
296 |
297 | func (dec *Decoder) ReadUint16() (uint16, error) {
298 | var b [2]byte
299 | err := dec.Read(b[:])
300 | if err != nil {
301 | return 0, err
302 | }
303 | d := binary.BigEndian.Uint16(b[:])
304 | if d > 256 {
305 | return 0, fmt.Errorf("large int %d", d)
306 | }
307 | return d, nil
308 | }
309 |
310 | func (dec *Decoder) ReadUint64() (uint64, error) {
311 | var b [8]byte
312 | err := dec.Read(b[:])
313 | if err != nil {
314 | return 0, err
315 | }
316 | d := binary.BigEndian.Uint64(b[:])
317 | return d, nil
318 | }
319 |
320 | func (dec *Decoder) ReadInteger() (Integer, error) {
321 | il, err := dec.ReadInt()
322 | if err != nil {
323 | return Zero, err
324 | }
325 | b := make([]byte, il)
326 | err = dec.Read(b)
327 | if err != nil {
328 | return Zero, err
329 | }
330 | var d Integer
331 | d.i.SetBytes(b)
332 | return d, nil
333 | }
334 |
335 | func (dec *Decoder) ReadBytes() ([]byte, error) {
336 | l, err := dec.ReadInt()
337 | if err != nil {
338 | return nil, err
339 | }
340 | if l == 0 {
341 | return nil, nil
342 | }
343 | b := make([]byte, l)
344 | err = dec.Read(b)
345 | return b, err
346 | }
347 |
348 | func (dec *Decoder) ReadMagic() (bool, error) {
349 | var b [2]byte
350 | err := dec.Read(b[:])
351 | if err != nil {
352 | return false, err
353 | }
354 | if bytes.Equal(magic, b[:]) {
355 | return true, nil
356 | }
357 | if bytes.Equal(null, b[:]) {
358 | return false, nil
359 | }
360 | return false, fmt.Errorf("malformed %v", b)
361 | }
362 |
--------------------------------------------------------------------------------
/common/deposit.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/MixinNetwork/mixin/crypto"
7 | )
8 |
9 | type DepositData struct {
10 | Chain crypto.Hash
11 | AssetKey string
12 | TransactionHash string
13 | OutputIndex uint64
14 | Amount Integer
15 | }
16 |
17 | func (d *DepositData) Asset() *Asset {
18 | return &Asset{
19 | ChainId: d.Chain,
20 | AssetKey: d.AssetKey,
21 | }
22 | }
23 |
24 | func (d *DepositData) UniqueKey() crypto.Hash {
25 | index := fmt.Sprintf("%s:%d", d.TransactionHash, d.OutputIndex)
26 | return crypto.NewHash([]byte(index)).ForNetwork(d.Chain)
27 | }
28 |
29 | func (tx *Transaction) DepositData() *DepositData {
30 | if len(tx.Inputs) != 1 {
31 | return nil
32 | }
33 | return tx.Inputs[0].Deposit
34 | }
35 |
36 | func (tx *Transaction) verifyDepositFormat() error {
37 | deposit := tx.Inputs[0].Deposit
38 | if err := deposit.Asset().Verify(); err != nil {
39 | return fmt.Errorf("invalid asset data %s", err.Error())
40 | }
41 | if id := deposit.Asset().AssetId(); id != tx.Asset {
42 | return fmt.Errorf("invalid asset %s %s", tx.Asset, id)
43 | }
44 | if deposit.Amount.Sign() <= 0 {
45 | return fmt.Errorf("invalid amount %s", deposit.Amount.String())
46 | }
47 |
48 | return nil
49 | }
50 |
51 | func (tx *SignedTransaction) validateDeposit(store DataStore, msg []byte, payloadHash crypto.Hash, sigs []map[uint16]*crypto.Signature) error {
52 | if len(tx.Inputs) != 1 {
53 | return fmt.Errorf("invalid inputs count %d for deposit", len(tx.Inputs))
54 | }
55 | if len(tx.Outputs) != 1 {
56 | return fmt.Errorf("invalid outputs count %d for deposit", len(tx.Outputs))
57 | }
58 | if tx.Outputs[0].Type != OutputTypeScript {
59 | return fmt.Errorf("invalid deposit output type %d", tx.Outputs[0].Type)
60 | }
61 | if len(sigs) != 1 || len(sigs[0]) != 1 {
62 | return fmt.Errorf("invalid signatures count %d for deposit", len(sigs))
63 | }
64 | err := tx.verifyDepositFormat()
65 | if err != nil {
66 | return err
67 | }
68 |
69 | sig, valid := sigs[0][0], false
70 | if sig == nil {
71 | return fmt.Errorf("invalid domain signature index for deposit")
72 | }
73 | for _, d := range store.ReadDomains() {
74 | if d.Account.PublicSpendKey.Verify(msg, *sig) {
75 | valid = true
76 | }
77 | }
78 | if !valid {
79 | return fmt.Errorf("invalid domain signature for deposit")
80 | }
81 |
82 | return store.CheckDepositInput(tx.Inputs[0].Deposit, payloadHash)
83 | }
84 |
85 | func (tx *Transaction) AddDepositInput(data *DepositData) {
86 | tx.Inputs = append(tx.Inputs, &Input{
87 | Deposit: data,
88 | })
89 | }
90 |
--------------------------------------------------------------------------------
/common/domain.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type Domain struct {
4 | Account Address
5 | }
6 |
--------------------------------------------------------------------------------
/common/encoding.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/binary"
6 | "sort"
7 |
8 | "github.com/MixinNetwork/mixin/crypto"
9 | )
10 |
11 | var (
12 | magic = []byte{0x77, 0x77}
13 | null = []byte{0x00, 0x00}
14 | )
15 |
16 | type Encoder struct {
17 | buf *bytes.Buffer
18 | }
19 |
20 | func NewEncoder() *Encoder {
21 | return &Encoder{buf: new(bytes.Buffer)}
22 | }
23 |
24 | func (enc *Encoder) EncodeTransaction(signed *SignedTransaction) []byte {
25 | if signed.Version != TxVersion {
26 | panic(signed)
27 | }
28 | if len(signed.SignaturesSliceV1) > 0 {
29 | panic(signed)
30 | }
31 |
32 | enc.Write(magic)
33 | enc.Write([]byte{0x00, signed.Version})
34 | enc.Write(signed.Asset[:])
35 |
36 | il := len(signed.Inputs)
37 | enc.WriteInt(il)
38 | for _, in := range signed.Inputs {
39 | enc.EncodeInput(in)
40 | }
41 |
42 | ol := len(signed.Outputs)
43 | enc.WriteInt(ol)
44 | for _, out := range signed.Outputs {
45 | enc.EncodeOutput(out)
46 | }
47 |
48 | el := len(signed.Extra)
49 | enc.WriteInt(el)
50 | enc.Write(signed.Extra)
51 |
52 | sl := len(signed.SignaturesMap)
53 | enc.WriteInt(sl)
54 | for _, sm := range signed.SignaturesMap {
55 | enc.EncodeSignatures(sm)
56 | }
57 |
58 | return enc.buf.Bytes()
59 | }
60 |
61 | func (enc *Encoder) EncodeInput(in *Input) {
62 | enc.Write(in.Hash[:])
63 | enc.WriteInt(in.Index)
64 |
65 | enc.WriteInt(len(in.Genesis))
66 | enc.Write(in.Genesis)
67 |
68 | if d := in.Deposit; d == nil {
69 | enc.Write(null)
70 | } else {
71 | enc.Write(magic)
72 | enc.Write(d.Chain[:])
73 |
74 | enc.WriteInt(len(d.AssetKey))
75 | enc.Write([]byte(d.AssetKey))
76 |
77 | enc.WriteInt(len(d.TransactionHash))
78 | enc.Write([]byte(d.TransactionHash))
79 |
80 | enc.WriteUint64(d.OutputIndex)
81 | enc.WriteInteger(d.Amount)
82 | }
83 |
84 | if m := in.Mint; m == nil {
85 | enc.Write(null)
86 | } else {
87 | enc.Write(magic)
88 |
89 | enc.WriteInt(len(m.Group))
90 | enc.Write([]byte(m.Group))
91 |
92 | enc.WriteUint64(m.Batch)
93 | enc.WriteInteger(m.Amount)
94 | }
95 | }
96 |
97 | func (enc *Encoder) EncodeOutput(o *Output) {
98 | enc.Write([]byte{0x00, o.Type})
99 | enc.WriteInteger(o.Amount)
100 | enc.WriteInt(len(o.Keys))
101 | for _, k := range o.Keys {
102 | enc.Write(k[:])
103 | }
104 |
105 | enc.Write(o.Mask[:])
106 | enc.WriteInt(len(o.Script))
107 | enc.Write(o.Script)
108 |
109 | if w := o.Withdrawal; w == nil {
110 | enc.Write(null)
111 | } else {
112 | enc.Write(magic)
113 | enc.Write(w.Chain[:])
114 |
115 | enc.WriteInt(len(w.AssetKey))
116 | enc.Write([]byte(w.AssetKey))
117 |
118 | enc.WriteInt(len(w.Address))
119 | enc.Write([]byte(w.Address))
120 |
121 | enc.WriteInt(len(w.Tag))
122 | enc.Write([]byte(w.Tag))
123 | }
124 | }
125 |
126 | func (enc *Encoder) EncodeSignatures(sm map[uint16]*crypto.Signature) {
127 | ss, off := make([]struct {
128 | Index uint16
129 | Sig *crypto.Signature
130 | }, len(sm)), 0
131 | for j, sig := range sm {
132 | ss[off].Index = j
133 | ss[off].Sig = sig
134 | off += 1
135 | }
136 | sort.Slice(ss, func(i, j int) bool { return ss[i].Index < ss[j].Index })
137 |
138 | enc.WriteInt(len(ss))
139 | for _, sp := range ss {
140 | enc.WriteUint16(sp.Index)
141 | enc.Write(sp.Sig[:])
142 | }
143 | }
144 |
145 | func (enc *Encoder) Write(b []byte) {
146 | l, err := enc.buf.Write(b)
147 | if err != nil {
148 | panic(err)
149 | }
150 | if l != len(b) {
151 | panic(b)
152 | }
153 | }
154 |
155 | func (enc *Encoder) WriteInt(d int) {
156 | if d > 256 {
157 | panic(d)
158 | }
159 | b := uint16ToByte(uint16(d))
160 | enc.Write(b)
161 | }
162 |
163 | func (enc *Encoder) WriteUint16(d uint16) {
164 | if d > 256 {
165 | panic(d)
166 | }
167 | b := uint16ToByte(d)
168 | enc.Write(b)
169 | }
170 |
171 | func (enc *Encoder) WriteUint64(d uint64) {
172 | b := uint64ToByte(d)
173 | enc.Write(b)
174 | }
175 |
176 | func (enc *Encoder) WriteInteger(d Integer) {
177 | b := d.i.Bytes()
178 | enc.WriteInt(len(b))
179 | enc.Write(b)
180 | }
181 |
182 | func uint16ToByte(d uint16) []byte {
183 | b := make([]byte, 2)
184 | binary.BigEndian.PutUint16(b, d)
185 | return b
186 | }
187 |
188 | func uint64ToByte(d uint64) []byte {
189 | b := make([]byte, 8)
190 | binary.BigEndian.PutUint64(b, d)
191 | return b
192 | }
193 |
--------------------------------------------------------------------------------
/common/go.mod:
--------------------------------------------------------------------------------
1 | module common
2 |
3 | go 1.16
4 |
5 | require (
6 | github.com/MixinNetwork/mixin v0.12.15
7 | github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce
8 | github.com/gopherjs/gopherjs v0.0.0-20210503212227-fb464eba2686
9 | github.com/shopspring/decimal v1.2.0
10 | golang.org/x/crypto v0.0.0-20210505212654-3497b51f5e64 // indirect
11 | golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 // indirect
12 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect
13 | )
14 |
--------------------------------------------------------------------------------
/common/integer.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "math/big"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/shopspring/decimal"
11 | )
12 |
13 | const Precision = 8
14 |
15 | var Zero Integer
16 |
17 | func init() {
18 | Zero = NewInteger(0)
19 | }
20 |
21 | type Integer struct {
22 | i big.Int
23 | }
24 |
25 | func NewIntegerFromString(x string) (v Integer) {
26 | d, err := decimal.NewFromString(x)
27 | if err != nil {
28 | panic(err)
29 | }
30 | if d.Sign() <= 0 {
31 | panic(x)
32 | }
33 | s := d.Mul(decimal.New(1, Precision)).StringFixed(0)
34 | v.i.SetString(s, 10)
35 | return
36 | }
37 |
38 | func NewInteger(x uint64) (v Integer) {
39 | p := new(big.Int).SetUint64(x)
40 | d := big.NewInt(int64(math.Pow(10, Precision)))
41 | v.i.Mul(p, d)
42 | return
43 | }
44 |
45 | func (x Integer) Add(y Integer) (v Integer) {
46 | if x.Sign() < 0 || y.Sign() <= 0 {
47 | panic(fmt.Sprint(x, y))
48 | }
49 |
50 | v.i.Add(&x.i, &y.i)
51 | if v.Cmp(x) < 0 || v.Cmp(y) < 0 {
52 | panic(fmt.Sprint(x, y))
53 | }
54 | return
55 | }
56 |
57 | func (x Integer) Sub(y Integer) (v Integer) {
58 | if x.Sign() < 0 || y.Sign() <= 0 {
59 | panic(fmt.Sprint(x, y))
60 | }
61 | if x.Cmp(y) < 0 {
62 | panic(fmt.Sprint(x, y))
63 | }
64 |
65 | v.i.Sub(&x.i, &y.i)
66 | return
67 | }
68 |
69 | func (x Integer) Mul(y int) (v Integer) {
70 | if x.Sign() < 0 || y <= 0 {
71 | panic(fmt.Sprint(x, y))
72 | }
73 |
74 | v.i.Mul(&x.i, big.NewInt(int64(y)))
75 | return
76 | }
77 |
78 | func (x Integer) Div(y int) (v Integer) {
79 | if x.Sign() < 0 || y <= 0 {
80 | panic(fmt.Sprint(x, y))
81 | }
82 |
83 | v.i.Div(&x.i, big.NewInt(int64(y)))
84 | return
85 | }
86 |
87 | func (x Integer) Cmp(y Integer) int {
88 | return x.i.Cmp(&y.i)
89 | }
90 |
91 | func (x Integer) Sign() int {
92 | return x.i.Sign()
93 | }
94 |
95 | func (x Integer) String() string {
96 | s := x.i.String()
97 | p := len(s) - Precision
98 | if p > 0 {
99 | return s[:p] + "." + s[p:]
100 | }
101 | return "0." + strings.Repeat("0", -p) + s
102 | }
103 |
104 | func (x Integer) MarshalMsgpack() ([]byte, error) {
105 | return x.i.Bytes(), nil
106 | }
107 |
108 | func (x *Integer) UnmarshalMsgpack(data []byte) error {
109 | x.i.SetBytes(data)
110 | return nil
111 | }
112 |
113 | func (x Integer) MarshalJSON() ([]byte, error) {
114 | s := x.String()
115 | return []byte(strconv.Quote(s)), nil
116 | }
117 |
118 | func (x *Integer) UnmarshalJSON(b []byte) error {
119 | unquoted, err := strconv.Unquote(string(b))
120 | if err != nil {
121 | return err
122 | }
123 | i := NewIntegerFromString(unquoted)
124 | x.i.SetBytes(i.i.Bytes())
125 | return nil
126 | }
127 |
--------------------------------------------------------------------------------
/common/mint.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/MixinNetwork/mixin/crypto"
7 | )
8 |
9 | const (
10 | MintGroupKernelNode = "KERNELNODE"
11 | )
12 |
13 | type MintData struct {
14 | Group string
15 | Batch uint64
16 | Amount Integer
17 | }
18 |
19 | type MintDistribution struct {
20 | MintData
21 | Transaction crypto.Hash
22 | }
23 |
24 | func (m *MintData) Distribute(tx crypto.Hash) *MintDistribution {
25 | return &MintDistribution{
26 | MintData: *m,
27 | Transaction: tx,
28 | }
29 | }
30 |
31 | func (tx *VersionedTransaction) validateMint(store DataStore) error {
32 | if len(tx.Inputs) != 1 {
33 | return fmt.Errorf("invalid inputs count %d for mint", len(tx.Inputs))
34 | }
35 | for _, out := range tx.Outputs {
36 | if out.Type != OutputTypeScript {
37 | return fmt.Errorf("invalid mint output type %d", out.Type)
38 | }
39 | }
40 | if tx.Asset != XINAssetId {
41 | return fmt.Errorf("invalid mint asset %s", tx.Asset.String())
42 | }
43 |
44 | mint := tx.Inputs[0].Mint
45 | if mint.Group != MintGroupKernelNode {
46 | return fmt.Errorf("invalid mint group %s", mint.Group)
47 | }
48 |
49 | dist, err := store.ReadLastMintDistribution(mint.Group)
50 | if err != nil {
51 | return err
52 | }
53 | if mint.Batch > dist.Batch {
54 | return nil
55 | }
56 | if mint.Batch < dist.Batch {
57 | return fmt.Errorf("backward mint batch %d %d", dist.Batch, mint.Batch)
58 | }
59 | if dist.Transaction != tx.PayloadHash() || dist.Amount.Cmp(mint.Amount) != 0 {
60 | return fmt.Errorf("invalid mint lock %s %s", dist.Transaction.String(), tx.PayloadHash().String())
61 | }
62 | return nil
63 | }
64 |
65 | func (tx *Transaction) AddKernelNodeMintInput(batch uint64, amount Integer) {
66 | tx.Inputs = append(tx.Inputs, &Input{
67 | Mint: &MintData{
68 | Group: MintGroupKernelNode,
69 | Batch: batch,
70 | Amount: amount,
71 | },
72 | })
73 | }
74 |
--------------------------------------------------------------------------------
/common/node.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/hex"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/MixinNetwork/mixin/crypto"
10 | )
11 |
12 | const (
13 | NodeStatePledging = "PLEDGING"
14 | NodeStateAccepted = "ACCEPTED"
15 | NodeStateRemoved = "REMOVED"
16 | NodeStateCancelled = "CANCELLED"
17 | )
18 |
19 | type Node struct {
20 | Signer Address
21 | Payee Address
22 | State string
23 | Transaction crypto.Hash
24 | Timestamp uint64
25 | }
26 |
27 | func (n *Node) IdForNetwork(networkId crypto.Hash) crypto.Hash {
28 | return n.Signer.Hash().ForNetwork(networkId)
29 | }
30 |
31 | func (tx *Transaction) validateNodePledge(store DataStore, inputs map[string]*UTXO) error {
32 | if tx.Asset != XINAssetId {
33 | return fmt.Errorf("invalid node asset %s", tx.Asset.String())
34 | }
35 | if len(tx.Outputs) != 1 {
36 | return fmt.Errorf("invalid outputs count %d for pledge transaction", len(tx.Outputs))
37 | }
38 | if len(tx.Extra) != 2*len(crypto.Key{}) {
39 | return fmt.Errorf("invalid extra length %d for pledge transaction", len(tx.Extra))
40 | }
41 | for _, in := range inputs {
42 | if in.Type != OutputTypeScript {
43 | return fmt.Errorf("invalid utxo type %d", in.Type)
44 | }
45 | }
46 |
47 | var signerSpend crypto.Key
48 | copy(signerSpend[:], tx.Extra)
49 | nodes := store.ReadAllNodes(uint64(time.Now().UnixNano()), false) // FIXME offset incorrect
50 | for _, n := range nodes {
51 | if n.State != NodeStateAccepted && n.State != NodeStateCancelled && n.State != NodeStateRemoved {
52 | return fmt.Errorf("invalid node pending state %s %s", n.Signer.String(), n.State)
53 | }
54 | if n.Signer.PublicSpendKey.String() == signerSpend.String() {
55 | return fmt.Errorf("invalid node signer key %s %s", hex.EncodeToString(tx.Extra), n.Signer)
56 | }
57 | if n.Payee.PublicSpendKey.String() == signerSpend.String() {
58 | return fmt.Errorf("invalid node signer key %s %s", hex.EncodeToString(tx.Extra), n.Payee)
59 | }
60 | }
61 |
62 | return nil
63 | }
64 |
65 | func (tx *Transaction) validateNodeCancel(store DataStore, msg []byte, sigs []map[uint16]*crypto.Signature) error {
66 | if tx.Asset != XINAssetId {
67 | return fmt.Errorf("invalid node asset %s", tx.Asset.String())
68 | }
69 | if len(tx.Outputs) != 2 {
70 | return fmt.Errorf("invalid outputs count %d for cancel transaction", len(tx.Outputs))
71 | }
72 | if len(tx.Inputs) != 1 {
73 | return fmt.Errorf("invalid inputs count %d for cancel transaction", len(tx.Inputs))
74 | }
75 | if len(sigs) != 1 || len(sigs[0]) != 1 || sigs[0][0] == nil {
76 | return fmt.Errorf("invalid signatures %v for cancel transaction", sigs)
77 | }
78 | if len(tx.Extra) != len(crypto.Key{})*3 {
79 | return fmt.Errorf("invalid extra %s for cancel transaction", hex.EncodeToString(tx.Extra))
80 | }
81 | cancel, script := tx.Outputs[0], tx.Outputs[1]
82 | if cancel.Type != OutputTypeNodeCancel || script.Type != OutputTypeScript {
83 | return fmt.Errorf("invalid outputs type %d %d for cancel transaction", cancel.Type, script.Type)
84 | }
85 | if len(script.Keys) != 1 {
86 | return fmt.Errorf("invalid script output keys %d for cancel transaction", len(script.Keys))
87 | }
88 | if script.Script.String() != NewThresholdScript(1).String() {
89 | return fmt.Errorf("invalid script output script %s for cancel transaction", script.Script)
90 | }
91 |
92 | var pledging *Node
93 | filter := make(map[string]string)
94 | nodes := store.ReadAllNodes(uint64(time.Now().UnixNano()), false) // FIXME offset incorrect
95 | for _, n := range nodes {
96 | filter[n.Signer.String()] = n.State
97 | if n.State == NodeStateAccepted || n.State == NodeStateCancelled || n.State == NodeStateRemoved {
98 | continue
99 | }
100 | if n.State == NodeStatePledging && pledging == nil {
101 | pledging = n
102 | } else {
103 | return fmt.Errorf("invalid pledging nodes %s %s", pledging.Signer.String(), n.Signer.String())
104 | }
105 | }
106 | if pledging == nil {
107 | return fmt.Errorf("no pledging node needs to get cancelled")
108 | }
109 | if pledging.Transaction != tx.Inputs[0].Hash {
110 | return fmt.Errorf("invalid plede utxo source %s %s", pledging.Transaction, tx.Inputs[0].Hash)
111 | }
112 |
113 | lastPledge, _, err := store.ReadTransaction(tx.Inputs[0].Hash)
114 | if err != nil {
115 | return err
116 | }
117 | if len(lastPledge.Outputs) != 1 {
118 | return fmt.Errorf("invalid pledge utxo count %d", len(lastPledge.Outputs))
119 | }
120 | po := lastPledge.Outputs[0]
121 | if po.Type != OutputTypeNodePledge {
122 | return fmt.Errorf("invalid pledge utxo type %d", po.Type)
123 | }
124 | if cancel.Amount.Cmp(po.Amount.Div(100)) != 0 {
125 | return fmt.Errorf("invalid script output amount %s for cancel transaction", cancel.Amount)
126 | }
127 | var publicSpend crypto.Key
128 | copy(publicSpend[:], lastPledge.Extra)
129 | privateView := publicSpend.DeterministicHashDerive()
130 | acc := Address{
131 | PublicViewKey: privateView.Public(),
132 | PublicSpendKey: publicSpend,
133 | }
134 | if filter[acc.String()] != NodeStatePledging {
135 | return fmt.Errorf("invalid pledge utxo source %s", filter[acc.String()])
136 | }
137 |
138 | pit, _, err := store.ReadTransaction(lastPledge.Inputs[0].Hash)
139 | if err != nil {
140 | return err
141 | }
142 | if pit == nil {
143 | return fmt.Errorf("invalid pledge input source %s:%d", lastPledge.Inputs[0].Hash, lastPledge.Inputs[0].Index)
144 | }
145 | pi := pit.Outputs[lastPledge.Inputs[0].Index]
146 | if len(pi.Keys) != 1 {
147 | return fmt.Errorf("invalid pledge input source keys %d", len(pi.Keys))
148 | }
149 | var a crypto.Key
150 | copy(a[:], tx.Extra[len(crypto.Key{})*2:])
151 | pledgeSpend := crypto.ViewGhostOutputKey(&pi.Keys[0], &a, &pi.Mask, uint64(lastPledge.Inputs[0].Index))
152 | targetSpend := crypto.ViewGhostOutputKey(&script.Keys[0], &a, &script.Mask, 1)
153 | if !bytes.Equal(lastPledge.Extra, tx.Extra[:len(crypto.Key{})*2]) {
154 | return fmt.Errorf("invalid pledge and cancel key %s %s", hex.EncodeToString(lastPledge.Extra), hex.EncodeToString(tx.Extra))
155 | }
156 | if !bytes.Equal(pledgeSpend[:], targetSpend[:]) {
157 | return fmt.Errorf("invalid pledge and cancel target %s %s", pledgeSpend, targetSpend)
158 | }
159 | if !pi.Keys[0].Verify(msg, *sigs[0][0]) {
160 | return fmt.Errorf("invalid cancel signature %s", sigs[0][0])
161 | }
162 | return nil
163 | }
164 |
165 | func (tx *Transaction) validateNodeAccept(store DataStore) error {
166 | if tx.Asset != XINAssetId {
167 | return fmt.Errorf("invalid node asset %s", tx.Asset.String())
168 | }
169 | if len(tx.Outputs) != 1 {
170 | return fmt.Errorf("invalid outputs count %d for accept transaction", len(tx.Outputs))
171 | }
172 | if len(tx.Inputs) != 1 {
173 | return fmt.Errorf("invalid inputs count %d for accept transaction", len(tx.Inputs))
174 | }
175 | var pledging *Node
176 | filter := make(map[string]string)
177 | nodes := store.ReadAllNodes(uint64(time.Now().UnixNano()), false) // FIXME offset incorrect
178 | for _, n := range nodes {
179 | filter[n.Signer.String()] = n.State
180 | if n.State == NodeStateAccepted || n.State == NodeStateCancelled || n.State == NodeStateRemoved {
181 | continue
182 | }
183 | if n.State == NodeStatePledging && pledging == nil {
184 | pledging = n
185 | } else {
186 | return fmt.Errorf("invalid pledging nodes %s %s", pledging.Signer.String(), n.Signer.String())
187 | }
188 | }
189 | if pledging == nil {
190 | return fmt.Errorf("no pledging node needs to get accepted")
191 | }
192 | if pledging.Transaction != tx.Inputs[0].Hash {
193 | return fmt.Errorf("invalid plede utxo source %s %s", pledging.Transaction, tx.Inputs[0].Hash)
194 | }
195 |
196 | lastPledge, _, err := store.ReadTransaction(tx.Inputs[0].Hash)
197 | if err != nil {
198 | return err
199 | }
200 | if len(lastPledge.Outputs) != 1 {
201 | return fmt.Errorf("invalid pledge utxo count %d", len(lastPledge.Outputs))
202 | }
203 | po := lastPledge.Outputs[0]
204 | if po.Type != OutputTypeNodePledge {
205 | return fmt.Errorf("invalid pledge utxo type %d", po.Type)
206 | }
207 | var publicSpend crypto.Key
208 | copy(publicSpend[:], lastPledge.Extra)
209 | privateView := publicSpend.DeterministicHashDerive()
210 | acc := Address{
211 | PublicViewKey: privateView.Public(),
212 | PublicSpendKey: publicSpend,
213 | }
214 | if filter[acc.String()] != NodeStatePledging {
215 | return fmt.Errorf("invalid pledge utxo source %s", filter[acc.String()])
216 | }
217 | if !bytes.Equal(lastPledge.Extra, tx.Extra) {
218 | return fmt.Errorf("invalid pledge and accpet key %s %s", hex.EncodeToString(lastPledge.Extra), hex.EncodeToString(tx.Extra))
219 | }
220 | return nil
221 | }
222 |
223 | func (tx *Transaction) validateNodeRemove(store DataStore) error {
224 | if tx.Asset != XINAssetId {
225 | return fmt.Errorf("invalid node asset %s", tx.Asset.String())
226 | }
227 | if len(tx.Outputs) != 1 {
228 | return fmt.Errorf("invalid outputs count %d for remove transaction", len(tx.Outputs))
229 | }
230 | if len(tx.Inputs) != 1 {
231 | return fmt.Errorf("invalid inputs count %d for remove transaction", len(tx.Inputs))
232 | }
233 |
234 | accept, _, err := store.ReadTransaction(tx.Inputs[0].Hash)
235 | if err != nil {
236 | return err
237 | }
238 | if accept.PayloadHash() != tx.Inputs[0].Hash {
239 | return fmt.Errorf("accept transaction malformed %s %s", tx.Inputs[0].Hash, accept.PayloadHash())
240 | }
241 | if len(accept.Outputs) != 1 {
242 | return fmt.Errorf("invalid accept utxo count %d", len(accept.Outputs))
243 | }
244 | ao := accept.Outputs[0]
245 | if ao.Type != OutputTypeNodeAccept {
246 | return fmt.Errorf("invalid accept utxo type %d", ao.Type)
247 | }
248 | if !bytes.Equal(accept.Extra, tx.Extra) {
249 | return fmt.Errorf("invalid accept and remove key %s %s", hex.EncodeToString(accept.Extra), hex.EncodeToString(tx.Extra))
250 | }
251 | return nil
252 | }
253 |
--------------------------------------------------------------------------------
/common/ration.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math/big"
6 | )
7 |
8 | var OneRat RationalNumber
9 |
10 | func init() {
11 | OneRat = NewInteger(1).Ration(NewInteger(1))
12 | }
13 |
14 | type RationalNumber struct {
15 | x big.Int
16 | y big.Int
17 | }
18 |
19 | func (x Integer) Ration(y Integer) (v RationalNumber) {
20 | if x.Sign() < 0 || y.Sign() <= 0 {
21 | panic(fmt.Sprint(x, y))
22 | }
23 |
24 | v.x.SetBytes(x.i.Bytes())
25 | v.y.SetBytes(y.i.Bytes())
26 | return
27 | }
28 |
29 | func (r RationalNumber) Product(x Integer) (v Integer) {
30 | if x.Sign() < 0 {
31 | panic(fmt.Sprint(x, r))
32 | }
33 |
34 | v.i.Mul(&x.i, &r.x)
35 | v.i.Div(&v.i, &r.y)
36 | return
37 | }
38 |
39 | func (r RationalNumber) Cmp(x RationalNumber) int {
40 | var v RationalNumber
41 | v.x.Mul(&r.x, &x.y)
42 | v.y.Mul(&r.y, &x.x)
43 | return v.x.Cmp(&v.y)
44 | }
45 |
--------------------------------------------------------------------------------
/common/script.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/hex"
5 | "fmt"
6 | "strconv"
7 | )
8 |
9 | const (
10 | Operator0 = 0x00
11 | Operator64 = 0x40
12 | OperatorSum = 0xfe
13 | OperatorCmp = 0xff
14 | )
15 |
16 | type Script []uint8
17 |
18 | func NewThresholdScript(threshold uint8) Script {
19 | return Script{OperatorCmp, OperatorSum, threshold}
20 | }
21 |
22 | func (s Script) VerifyFormat() error {
23 | if len(s) != 3 {
24 | return fmt.Errorf("invalid script length %d", len(s))
25 | }
26 | if s[0] != OperatorCmp || s[1] != OperatorSum {
27 | return fmt.Errorf("invalid script operators %d %d", s[0], s[1])
28 | }
29 | if s[2] > Operator64 {
30 | return fmt.Errorf("invalid script threshold %d", s[2])
31 | }
32 | return nil
33 | }
34 |
35 | func (s Script) Validate(sum int) error {
36 | err := s.VerifyFormat()
37 | if err != nil {
38 | return err
39 | }
40 | if sum < int(s[2]) {
41 | return fmt.Errorf("invalid signature keys %d %d", sum, s[2])
42 | }
43 | return nil
44 | }
45 |
46 | func (s Script) String() string {
47 | return hex.EncodeToString(s[:])
48 | }
49 |
50 | func (s Script) MarshalJSON() ([]byte, error) {
51 | return []byte(strconv.Quote(s.String())), nil
52 | }
53 |
54 | func (s *Script) UnmarshalJSON(b []byte) error {
55 | unquoted, err := strconv.Unquote(string(b))
56 | if err != nil {
57 | return err
58 | }
59 | data, err := hex.DecodeString(string(unquoted))
60 | if err != nil {
61 | return err
62 | }
63 | *s = data
64 | return nil
65 | }
66 |
--------------------------------------------------------------------------------
/common/transaction.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/rand"
5 | "fmt"
6 |
7 | "github.com/MixinNetwork/mixin/crypto"
8 | )
9 |
10 | const (
11 | MainnetId = "6430225c42bb015b4da03102fa962e4f4ef3969e03e04345db229f8377ef7997"
12 | TransactionMaximumSize = 1024 * 1024
13 | WithdrawalClaimFee = "0.0001"
14 | )
15 |
16 | const (
17 | TxVersion = 0x02
18 | ExtraSizeLimit = 256
19 |
20 | OutputTypeScript = 0x00
21 | OutputTypeWithdrawalSubmit = 0xa1
22 | OutputTypeWithdrawalFuel = 0xa2
23 | OutputTypeNodePledge = 0xa3
24 | OutputTypeNodeAccept = 0xa4
25 | outputTypeNodeResign = 0xa5
26 | OutputTypeNodeRemove = 0xa6
27 | OutputTypeDomainAccept = 0xa7
28 | OutputTypeDomainRemove = 0xa8
29 | OutputTypeWithdrawalClaim = 0xa9
30 | OutputTypeNodeCancel = 0xaa
31 | OutputTypeDomainAssetCustody = 0xab
32 | OutputTypeDomainAssetRelease = 0xac
33 | OutputTypeDomainAssetMigrate = 0xad
34 |
35 | TransactionTypeScript = 0x00
36 | TransactionTypeMint = 0x01
37 | TransactionTypeDeposit = 0x02
38 | TransactionTypeWithdrawalSubmit = 0x03
39 | TransactionTypeWithdrawalFuel = 0x04
40 | TransactionTypeWithdrawalClaim = 0x05
41 | TransactionTypeNodePledge = 0x06
42 | TransactionTypeNodeAccept = 0x07
43 | transactionTypeNodeResign = 0x08
44 | TransactionTypeNodeRemove = 0x09
45 | TransactionTypeDomainAccept = 0x10
46 | TransactionTypeDomainRemove = 0x11
47 | TransactionTypeNodeCancel = 0x12
48 | TransactionTypeUnknown = 0xff
49 | )
50 |
51 | type Input struct {
52 | Hash crypto.Hash
53 | Index int
54 | Genesis []byte
55 | Deposit *DepositData
56 | Mint *MintData
57 | }
58 |
59 | type Output struct {
60 | Type uint8
61 | Amount Integer
62 | Keys []crypto.Key
63 | Withdrawal *WithdrawalData `msgpack:",omitempty"`
64 |
65 | // OutputTypeScript fields
66 | Script Script
67 | Mask crypto.Key
68 | }
69 |
70 | type Transaction struct {
71 | Version uint8
72 | Asset crypto.Hash
73 | Inputs []*Input
74 | Outputs []*Output
75 | Extra []byte
76 | }
77 |
78 | type SignedTransaction struct {
79 | Transaction
80 | SignaturesMap []map[uint16]*crypto.Signature `msgpack:"Signatures"`
81 | SignaturesSliceV1 [][]*crypto.Signature `msgpack:"-"`
82 | }
83 |
84 | func (tx *Transaction) ViewGhostKey(a *crypto.Key) []*Output {
85 | outputs := make([]*Output, 0)
86 |
87 | for i, o := range tx.Outputs {
88 | if o.Type != OutputTypeScript {
89 | continue
90 | }
91 |
92 | out := &Output{
93 | Type: o.Type,
94 | Amount: o.Amount,
95 | Script: o.Script,
96 | Mask: o.Mask,
97 | }
98 | for _, k := range o.Keys {
99 | key := crypto.ViewGhostOutputKey(&k, a, &o.Mask, uint64(i))
100 | out.Keys = append(out.Keys, *key)
101 | }
102 | outputs = append(outputs, out)
103 | }
104 |
105 | return outputs
106 | }
107 |
108 | func (tx *SignedTransaction) TransactionType() uint8 {
109 | for _, in := range tx.Inputs {
110 | if in.Mint != nil {
111 | return TransactionTypeMint
112 | }
113 | if in.Deposit != nil {
114 | return TransactionTypeDeposit
115 | }
116 | if in.Genesis != nil {
117 | return TransactionTypeUnknown
118 | }
119 | }
120 |
121 | isScript := true
122 | for _, out := range tx.Outputs {
123 | switch out.Type {
124 | case OutputTypeWithdrawalSubmit:
125 | return TransactionTypeWithdrawalSubmit
126 | case OutputTypeWithdrawalFuel:
127 | return TransactionTypeWithdrawalFuel
128 | case OutputTypeWithdrawalClaim:
129 | return TransactionTypeWithdrawalClaim
130 | case OutputTypeNodePledge:
131 | return TransactionTypeNodePledge
132 | case OutputTypeNodeCancel:
133 | return TransactionTypeNodeCancel
134 | case OutputTypeNodeAccept:
135 | return TransactionTypeNodeAccept
136 | case OutputTypeNodeRemove:
137 | return TransactionTypeNodeRemove
138 | case OutputTypeDomainAccept:
139 | return TransactionTypeDomainAccept
140 | case OutputTypeDomainRemove:
141 | return TransactionTypeDomainRemove
142 | }
143 | isScript = isScript && out.Type == OutputTypeScript
144 | }
145 |
146 | if isScript {
147 | return TransactionTypeScript
148 | }
149 | return TransactionTypeUnknown
150 | }
151 |
152 | func (signed *SignedTransaction) SignUTXO(utxo *UTXO, accounts []*Address) error {
153 | msg := signed.AsLatestVersion().PayloadMarshal()
154 |
155 | if len(accounts) == 0 {
156 | return nil
157 | }
158 |
159 | keysFilter := make(map[string]uint16)
160 | for i, k := range utxo.Keys {
161 | keysFilter[k.String()] = uint16(i)
162 | }
163 |
164 | sigs := make(map[uint16]*crypto.Signature)
165 | for _, acc := range accounts {
166 | priv := crypto.DeriveGhostPrivateKey(&utxo.Mask, &acc.PrivateViewKey, &acc.PrivateSpendKey, uint64(utxo.Index))
167 | i, found := keysFilter[priv.Public().String()]
168 | if !found {
169 | return fmt.Errorf("invalid key for the input %s", acc.String())
170 | }
171 | sig := priv.Sign(msg)
172 | sigs[i] = &sig
173 | }
174 | signed.SignaturesMap = append(signed.SignaturesMap, sigs)
175 | return nil
176 | }
177 |
178 | func (signed *SignedTransaction) SignInput(reader UTXOReader, index uint64, accounts []*Address) error {
179 | msg := signed.AsLatestVersion().PayloadMarshal()
180 |
181 | if len(accounts) == 0 {
182 | return nil
183 | }
184 | if index >= uint64(len(signed.Inputs)) {
185 | return fmt.Errorf("invalid input index %d/%d", index, len(signed.Inputs))
186 | }
187 | in := signed.Inputs[index]
188 | if in.Deposit != nil || in.Mint != nil {
189 | return signed.SignRaw(accounts[0].PrivateSpendKey)
190 | }
191 |
192 | utxo, err := reader.ReadUTXO(in.Hash, in.Index)
193 | if err != nil {
194 | return err
195 | }
196 | if utxo == nil {
197 | return fmt.Errorf("input not found %s:%d", in.Hash.String(), in.Index)
198 | }
199 |
200 | keysFilter := make(map[string]uint16)
201 | for i, k := range utxo.Keys {
202 | keysFilter[k.String()] = uint16(i)
203 | }
204 |
205 | sigs := make(map[uint16]*crypto.Signature)
206 | for _, acc := range accounts {
207 | priv := crypto.DeriveGhostPrivateKey(&utxo.Mask, &acc.PrivateViewKey, &acc.PrivateSpendKey, uint64(in.Index))
208 | i, found := keysFilter[priv.Public().String()]
209 | if !found {
210 | return fmt.Errorf("invalid key for the input %s", acc.String())
211 | }
212 | sig := priv.Sign(msg)
213 | sigs[i] = &sig
214 | }
215 | signed.SignaturesMap = append(signed.SignaturesMap, sigs)
216 | return nil
217 | }
218 |
219 | func (signed *SignedTransaction) SignRaw(key crypto.Key) error {
220 | msg := signed.AsLatestVersion().PayloadMarshal()
221 |
222 | if len(signed.Inputs) != 1 {
223 | return fmt.Errorf("invalid inputs count %d", len(signed.Inputs))
224 | }
225 | in := signed.Inputs[0]
226 | if in.Deposit == nil && in.Mint == nil {
227 | return fmt.Errorf("invalid input format")
228 | }
229 | if in.Deposit != nil {
230 | err := signed.verifyDepositFormat()
231 | if err != nil {
232 | return err
233 | }
234 | }
235 | sig := key.Sign(msg)
236 | sigs := map[uint16]*crypto.Signature{0: &sig}
237 | signed.SignaturesMap = append(signed.SignaturesMap, sigs)
238 | return nil
239 | }
240 |
241 | func NewTransaction(asset crypto.Hash) *Transaction {
242 | return &Transaction{
243 | Version: TxVersion,
244 | Asset: asset,
245 | }
246 | }
247 |
248 | func (tx *Transaction) AddInput(hash crypto.Hash, index int) {
249 | in := &Input{
250 | Hash: hash,
251 | Index: index,
252 | }
253 | tx.Inputs = append(tx.Inputs, in)
254 | }
255 |
256 | func (tx *Transaction) AddOutputWithType(ot uint8, accounts []*Address, s Script, amount Integer, seed []byte) {
257 | out := &Output{
258 | Type: ot,
259 | Amount: amount,
260 | Script: s,
261 | Keys: make([]crypto.Key, 0),
262 | }
263 |
264 | if len(accounts) > 0 {
265 | r := crypto.NewKeyFromSeed(seed)
266 | out.Mask = r.Public()
267 | for _, a := range accounts {
268 | k := crypto.DeriveGhostPublicKey(&r, &a.PublicViewKey, &a.PublicSpendKey, uint64(len(tx.Outputs)))
269 | out.Keys = append(out.Keys, *k)
270 | }
271 | }
272 |
273 | tx.Outputs = append(tx.Outputs, out)
274 | }
275 |
276 | func (tx *Transaction) AddScriptOutput(accounts []*Address, s Script, amount Integer, seed []byte) {
277 | tx.AddOutputWithType(OutputTypeScript, accounts, s, amount, seed)
278 | }
279 |
280 | func (tx *Transaction) AddRandomScriptOutput(accounts []*Address, s Script, amount Integer) error {
281 | seed := make([]byte, 64)
282 | _, err := rand.Read(seed)
283 | if err != nil {
284 | return err
285 | }
286 | tx.AddScriptOutput(accounts, s, amount, seed)
287 | return nil
288 | }
289 |
--------------------------------------------------------------------------------
/common/transaction_js.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/hex"
5 | "encoding/json"
6 |
7 | "github.com/MixinNetwork/mixin/crypto"
8 | "github.com/gopherjs/gopherjs/js"
9 | )
10 |
11 | func main() {
12 | js.Global.Set("mixinGo", map[string]interface{}{
13 | "decodeTransaction": decodeTransaction,
14 | "buildTransaction": buildTransaction,
15 | })
16 | }
17 |
18 | func decodeTransaction(input string) string {
19 | raw, err := hex.DecodeString(input)
20 | if err != nil {
21 | return err.Error()
22 | }
23 | ver, err := UnmarshalVersionedTransaction(raw)
24 | if err != nil {
25 | return err.Error()
26 | }
27 | tm := transactionToMap(ver)
28 | data, err := json.Marshal(tm)
29 | if err != nil {
30 | return err.Error()
31 | }
32 | return string(data)
33 | }
34 |
35 | func buildTransaction(data string) string {
36 | var raw signerInput
37 | err := json.Unmarshal([]byte(data), &raw)
38 | if err != nil {
39 | return err.Error()
40 | }
41 |
42 | tx := NewTransaction(raw.Asset)
43 | for _, in := range raw.Inputs {
44 | tx.AddInput(in.Hash, in.Index)
45 | }
46 |
47 | for _, out := range raw.Outputs {
48 | if out.Mask.HasValue() {
49 | tx.Outputs = append(tx.Outputs, &Output{
50 | Type: out.Type,
51 | Amount: out.Amount,
52 | Keys: out.Keys,
53 | Script: out.Script,
54 | Mask: out.Mask,
55 | })
56 | }
57 | }
58 |
59 | extra, err := hex.DecodeString(raw.Extra)
60 | if err != nil {
61 | return err.Error()
62 | }
63 | tx.Extra = extra
64 |
65 | signed := tx.AsLatestVersion()
66 | return hex.EncodeToString(signed.Marshal())
67 | }
68 |
69 | type signerInput struct {
70 | Inputs []struct {
71 | Hash crypto.Hash `json:"hash"`
72 | Index int `json:"index"`
73 | Keys []crypto.Key `json:"keys"`
74 | Mask crypto.Key `json:"mask"`
75 | } `json:"inputs"`
76 | Outputs []struct {
77 | Type uint8 `json:"type"`
78 | Mask crypto.Key `json:"mask"`
79 | Keys []crypto.Key `json:"keys"`
80 | Amount Integer `json:"amount"`
81 | Script Script `json:"script"`
82 | Accounts []Address `json:"accounts"`
83 | }
84 | Asset crypto.Hash `json:"asset"`
85 | Extra string `json:"extra"`
86 | }
87 |
88 | func (raw signerInput) ReadUTXO(hash crypto.Hash, index int) (*UTXOWithLock, error) {
89 | return nil, nil
90 | }
91 |
92 | func (raw signerInput) CheckDepositInput(deposit *DepositData, tx crypto.Hash) error {
93 | return nil
94 | }
95 |
96 | func (raw signerInput) ReadLastMintDistribution(group string) (*MintDistribution, error) {
97 | return nil, nil
98 | }
99 |
100 | func transactionToMap(tx *VersionedTransaction) map[string]interface{} {
101 | var inputs []map[string]interface{}
102 | for _, in := range tx.Inputs {
103 | if in.Hash.HasValue() {
104 | inputs = append(inputs, map[string]interface{}{
105 | "hash": in.Hash,
106 | "index": in.Index,
107 | })
108 | }
109 | }
110 |
111 | var outputs []map[string]interface{}
112 | for _, out := range tx.Outputs {
113 | output := map[string]interface{}{
114 | "type": out.Type,
115 | "amount": out.Amount,
116 | }
117 | if len(out.Keys) > 0 {
118 | output["keys"] = out.Keys
119 | }
120 | if len(out.Script) > 0 {
121 | output["script"] = out.Script
122 | }
123 | if out.Mask.HasValue() {
124 | output["mask"] = out.Mask
125 | }
126 | outputs = append(outputs, output)
127 | }
128 |
129 | return map[string]interface{}{
130 | "version": tx.Version,
131 | "asset": tx.Asset,
132 | "inputs": inputs,
133 | "outputs": outputs,
134 | "extra": hex.EncodeToString(tx.Extra),
135 | "hash": tx.PayloadHash(),
136 | "hex": hex.EncodeToString(tx.PayloadMarshal()),
137 | "signatures": tx.SignaturesMap,
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/common/utxo.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/MixinNetwork/mixin/crypto"
5 | )
6 |
7 | type UTXO struct {
8 | Input
9 | Output
10 | Asset crypto.Hash
11 | }
12 |
13 | type UTXOWithLock struct {
14 | UTXO
15 | LockHash crypto.Hash
16 | }
17 |
18 | type UTXOReader interface {
19 | ReadUTXO(hash crypto.Hash, index int) (*UTXOWithLock, error)
20 | CheckDepositInput(deposit *DepositData, tx crypto.Hash) error
21 | ReadLastMintDistribution(group string) (*MintDistribution, error)
22 | }
23 |
24 | type UTXOLocker interface {
25 | LockUTXOs(inputs []*Input, tx crypto.Hash, fork bool) error
26 | LockDepositInput(deposit *DepositData, tx crypto.Hash, fork bool) error
27 | LockMintInput(mint *MintData, tx crypto.Hash, fork bool) error
28 | }
29 |
30 | type GhostChecker interface {
31 | CheckGhost(key crypto.Key) (bool, error)
32 | }
33 |
34 | type NodeReader interface {
35 | ReadAllNodes(offset uint64, withState bool) []*Node
36 | ReadTransaction(hash crypto.Hash) (*VersionedTransaction, string, error)
37 | }
38 |
39 | type DomainReader interface {
40 | ReadDomains() []Domain
41 | }
42 |
43 | type DataStore interface {
44 | UTXOReader
45 | UTXOLocker
46 | GhostChecker
47 | NodeReader
48 | DomainReader
49 | }
50 |
51 | func (tx *VersionedTransaction) UnspentOutputs() []*UTXO {
52 | var utxos []*UTXO
53 | for i, out := range tx.Outputs {
54 | switch out.Type {
55 | case OutputTypeScript,
56 | OutputTypeNodePledge,
57 | OutputTypeNodeCancel,
58 | OutputTypeNodeAccept,
59 | OutputTypeNodeRemove,
60 | OutputTypeDomainAccept,
61 | OutputTypeWithdrawalFuel,
62 | OutputTypeWithdrawalClaim:
63 | case OutputTypeWithdrawalSubmit:
64 | continue
65 | default:
66 | panic(out.Type)
67 | }
68 |
69 | utxo := &UTXO{
70 | Input: Input{
71 | Hash: tx.PayloadHash(),
72 | Index: i,
73 | },
74 | Output: Output{
75 | Type: out.Type,
76 | Amount: out.Amount,
77 | Keys: out.Keys,
78 | Script: out.Script,
79 | Mask: out.Mask,
80 | },
81 | Asset: tx.Asset,
82 | }
83 | utxos = append(utxos, utxo)
84 | }
85 | return utxos
86 | }
87 |
--------------------------------------------------------------------------------
/common/validation.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/MixinNetwork/mixin/crypto"
7 | )
8 |
9 | func (ver *VersionedTransaction) Validate(store DataStore) error {
10 | tx := &ver.SignedTransaction
11 | msg := ver.PayloadMarshal()
12 | txType := tx.TransactionType()
13 |
14 | if ver.Version != TxVersion {
15 | return fmt.Errorf("invalid tx version %d %d", ver.Version, tx.Version)
16 | }
17 | if txType == TransactionTypeUnknown {
18 | return fmt.Errorf("invalid tx type %d", txType)
19 | }
20 | if len(tx.Inputs) < 1 || len(tx.Outputs) < 1 {
21 | return fmt.Errorf("invalid tx inputs or outputs %d %d", len(tx.Inputs), len(tx.Outputs))
22 | }
23 | if len(tx.Inputs) != len(tx.SignaturesMap) && txType != TransactionTypeNodeAccept && txType != TransactionTypeNodeRemove {
24 | return fmt.Errorf("invalid tx signature number %d %d %d", len(tx.Inputs), len(tx.SignaturesMap), txType)
25 | }
26 | if len(tx.Extra) > ExtraSizeLimit {
27 | return fmt.Errorf("invalid extra size %d", len(tx.Extra))
28 | }
29 | if len(ver.Marshal()) > TransactionMaximumSize {
30 | return fmt.Errorf("invalid transaction size %d", len(msg))
31 | }
32 |
33 | inputsFilter, inputAmount, err := validateInputs(store, tx, msg, ver.PayloadHash(), txType)
34 | if err != nil {
35 | return err
36 | }
37 | outputAmount, err := tx.validateOutputs(store)
38 | if err != nil {
39 | return err
40 | }
41 |
42 | if inputAmount.Sign() <= 0 || inputAmount.Cmp(outputAmount) != 0 {
43 | return fmt.Errorf("invalid input output amount %s %s", inputAmount.String(), outputAmount.String())
44 | }
45 |
46 | switch txType {
47 | case TransactionTypeScript:
48 | return validateScriptTransaction(inputsFilter)
49 | case TransactionTypeMint:
50 | return ver.validateMint(store)
51 | case TransactionTypeDeposit:
52 | return tx.validateDeposit(store, msg, ver.PayloadHash(), ver.SignaturesMap)
53 | case TransactionTypeWithdrawalSubmit:
54 | return tx.validateWithdrawalSubmit(inputsFilter)
55 | case TransactionTypeWithdrawalFuel:
56 | return tx.validateWithdrawalFuel(store, inputsFilter)
57 | case TransactionTypeWithdrawalClaim:
58 | return tx.validateWithdrawalClaim(store, inputsFilter, msg)
59 | case TransactionTypeNodePledge:
60 | return tx.validateNodePledge(store, inputsFilter)
61 | case TransactionTypeNodeCancel:
62 | return tx.validateNodeCancel(store, msg, ver.SignaturesMap)
63 | case TransactionTypeNodeAccept:
64 | return tx.validateNodeAccept(store)
65 | case TransactionTypeNodeRemove:
66 | return tx.validateNodeRemove(store)
67 | case TransactionTypeDomainAccept:
68 | return fmt.Errorf("invalid transaction type %d", txType)
69 | case TransactionTypeDomainRemove:
70 | return fmt.Errorf("invalid transaction type %d", txType)
71 | }
72 | return fmt.Errorf("invalid transaction type %d", txType)
73 | }
74 |
75 | func validateScriptTransaction(inputs map[string]*UTXO) error {
76 | for _, in := range inputs {
77 | if in.Type != OutputTypeScript && in.Type != OutputTypeNodeRemove {
78 | return fmt.Errorf("invalid utxo type %d", in.Type)
79 | }
80 | }
81 | return nil
82 | }
83 |
84 | func validateInputs(store DataStore, tx *SignedTransaction, msg []byte, hash crypto.Hash, txType uint8) (map[string]*UTXO, Integer, error) {
85 | inputAmount := NewInteger(0)
86 | inputsFilter := make(map[string]*UTXO)
87 | keySigs := make(map[*crypto.Key]*crypto.Signature)
88 |
89 | for i, in := range tx.Inputs {
90 | if in.Mint != nil {
91 | return inputsFilter, in.Mint.Amount, nil
92 | }
93 |
94 | if in.Deposit != nil {
95 | return inputsFilter, in.Deposit.Amount, nil
96 | }
97 |
98 | fk := fmt.Sprintf("%s:%d", in.Hash.String(), in.Index)
99 | if inputsFilter[fk] != nil {
100 | return inputsFilter, inputAmount, fmt.Errorf("invalid input %s", fk)
101 | }
102 |
103 | utxo, err := store.ReadUTXO(in.Hash, in.Index)
104 | if err != nil {
105 | return inputsFilter, inputAmount, err
106 | }
107 | if utxo == nil {
108 | return inputsFilter, inputAmount, fmt.Errorf("input not found %s:%d", in.Hash.String(), in.Index)
109 | }
110 | if utxo.Asset != tx.Asset {
111 | return inputsFilter, inputAmount, fmt.Errorf("invalid input asset %s %s", utxo.Asset.String(), tx.Asset.String())
112 | }
113 | if utxo.LockHash.HasValue() && utxo.LockHash != hash {
114 | return inputsFilter, inputAmount, fmt.Errorf("input locked for transaction %s", utxo.LockHash)
115 | }
116 |
117 | err = validateUTXO(i, &utxo.UTXO, tx.SignaturesMap, msg, txType, keySigs)
118 | if err != nil {
119 | return inputsFilter, inputAmount, err
120 | }
121 | inputsFilter[fk] = &utxo.UTXO
122 | inputAmount = inputAmount.Add(utxo.Amount)
123 | }
124 |
125 | if len(keySigs) == 0 && (txType == TransactionTypeNodeAccept || txType == TransactionTypeNodeRemove) {
126 | return inputsFilter, inputAmount, nil
127 | }
128 |
129 | var keys []*crypto.Key
130 | var sigs []*crypto.Signature
131 | for k, s := range keySigs {
132 | keys = append(keys, k)
133 | sigs = append(sigs, s)
134 | }
135 | if !crypto.BatchVerify(msg, keys, sigs) {
136 | return inputsFilter, inputAmount, fmt.Errorf("batch verification failure %d %d", len(keys), len(sigs))
137 | }
138 | return inputsFilter, inputAmount, nil
139 | }
140 |
141 | func (tx *Transaction) validateOutputs(store DataStore) (Integer, error) {
142 | outputAmount := NewInteger(0)
143 | outputsFilter := make(map[crypto.Key]bool)
144 | for _, o := range tx.Outputs {
145 | if o.Amount.Sign() <= 0 {
146 | return outputAmount, fmt.Errorf("invalid output amount %s", o.Amount.String())
147 | }
148 |
149 | if o.Withdrawal != nil {
150 | outputAmount = outputAmount.Add(o.Amount)
151 | continue
152 | }
153 |
154 | for _, k := range o.Keys {
155 | if outputsFilter[k] {
156 | return outputAmount, fmt.Errorf("invalid output key %s", k.String())
157 | }
158 | outputsFilter[k] = true
159 | if !k.CheckKey() {
160 | return outputAmount, fmt.Errorf("invalid output key format %s", k.String())
161 | }
162 | exist, err := store.CheckGhost(k)
163 | if err != nil {
164 | return outputAmount, err
165 | } else if exist {
166 | return outputAmount, fmt.Errorf("invalid output key %s", k.String())
167 | }
168 | }
169 |
170 | switch o.Type {
171 | case OutputTypeWithdrawalSubmit,
172 | OutputTypeWithdrawalFuel,
173 | OutputTypeWithdrawalClaim,
174 | OutputTypeNodePledge,
175 | OutputTypeNodeCancel,
176 | OutputTypeNodeAccept:
177 | if len(o.Keys) != 0 {
178 | return outputAmount, fmt.Errorf("invalid output keys count %d for kernel multisig transaction", len(o.Keys))
179 | }
180 | if len(o.Script) != 0 {
181 | return outputAmount, fmt.Errorf("invalid output script %s for kernel multisig transaction", o.Script)
182 | }
183 | if o.Mask.HasValue() {
184 | return outputAmount, fmt.Errorf("invalid output empty mask %s for kernel multisig transaction", o.Mask)
185 | }
186 | default:
187 | err := o.Script.VerifyFormat()
188 | if err != nil {
189 | return outputAmount, err
190 | }
191 | if !o.Mask.HasValue() {
192 | return outputAmount, fmt.Errorf("invalid script output empty mask %s", o.Mask)
193 | }
194 | if o.Withdrawal != nil {
195 | return outputAmount, fmt.Errorf("invalid script output with withdrawal %s", o.Withdrawal.Address)
196 | }
197 | }
198 | outputAmount = outputAmount.Add(o.Amount)
199 | }
200 | return outputAmount, nil
201 | }
202 |
203 | func validateUTXO(index int, utxo *UTXO, sigs []map[uint16]*crypto.Signature, msg []byte, txType uint8, keySigs map[*crypto.Key]*crypto.Signature) error {
204 | switch utxo.Type {
205 | case OutputTypeScript, OutputTypeNodeRemove:
206 | for i, sig := range sigs[index] {
207 | if int(i) >= len(utxo.Keys) {
208 | return fmt.Errorf("invalid signature map index %d %d", i, len(utxo.Keys))
209 | }
210 | keySigs[&utxo.Keys[i]] = sig
211 | }
212 | return utxo.Script.Validate(len(sigs[index]))
213 | case OutputTypeNodePledge:
214 | if txType == TransactionTypeNodeAccept || txType == TransactionTypeNodeCancel {
215 | return nil
216 | }
217 | return fmt.Errorf("pledge input used for invalid transaction type %d", txType)
218 | case OutputTypeNodeAccept:
219 | if txType == TransactionTypeNodeRemove {
220 | return nil
221 | }
222 | return fmt.Errorf("accept input used for invalid transaction type %d", txType)
223 | case OutputTypeNodeCancel:
224 | return fmt.Errorf("should do more validation on those %d UTXOs", utxo.Type)
225 | default:
226 | return fmt.Errorf("invalid input type %d", utxo.Type)
227 | }
228 | }
229 |
--------------------------------------------------------------------------------
/common/version.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/hex"
6 | "fmt"
7 |
8 | "github.com/MixinNetwork/mixin/crypto"
9 | )
10 |
11 | type VersionedTransaction struct {
12 | SignedTransaction
13 | }
14 |
15 | func (tx *SignedTransaction) AsLatestVersion() *VersionedTransaction {
16 | if tx.Version != TxVersion {
17 | panic(tx.Version)
18 | }
19 | return &VersionedTransaction{
20 | SignedTransaction: *tx,
21 | }
22 | }
23 |
24 | func (tx *Transaction) AsLatestVersion() *VersionedTransaction {
25 | if tx.Version != TxVersion {
26 | panic(tx.Version)
27 | }
28 | return &VersionedTransaction{
29 | SignedTransaction: SignedTransaction{Transaction: *tx},
30 | }
31 | }
32 |
33 | func UnmarshalVersionedTransaction(val []byte) (*VersionedTransaction, error) {
34 | ver, err := unmarshalVersionedTransaction(val)
35 | if err != nil {
36 | return nil, err
37 | }
38 | ret := ver.marshal()
39 | if !bytes.Equal(val, ret) {
40 | return nil, fmt.Errorf("malformed %d %d", len(ret), len(val))
41 | }
42 | return ver, nil
43 | }
44 |
45 | func (ver *VersionedTransaction) Marshal() []byte {
46 | val := ver.marshal()
47 | ret, err := unmarshalVersionedTransaction(val)
48 | if err != nil {
49 | panic(err)
50 | }
51 | retv := ret.marshal()
52 | if !bytes.Equal(retv, val) {
53 | panic(fmt.Errorf("malformed %s %s", hex.EncodeToString(val), hex.EncodeToString(retv)))
54 | }
55 | return val
56 | }
57 |
58 | func (ver *VersionedTransaction) PayloadMarshal() []byte {
59 | val := ver.payloadMarshal()
60 | ret, err := unmarshalVersionedTransaction(val)
61 | if err != nil {
62 | panic(err)
63 | }
64 | retv := ret.payloadMarshal()
65 | if !bytes.Equal(retv, val) {
66 | panic(fmt.Errorf("malformed %s %s", hex.EncodeToString(val), hex.EncodeToString(retv)))
67 | }
68 | return val
69 | }
70 |
71 | func (ver *VersionedTransaction) PayloadHash() crypto.Hash {
72 | return crypto.NewHash(ver.PayloadMarshal())
73 | }
74 |
75 | func unmarshalVersionedTransaction(val []byte) (*VersionedTransaction, error) {
76 | signed, err := NewDecoder(val).DecodeTransaction()
77 | if err != nil {
78 | return nil, err
79 | }
80 | ver := &VersionedTransaction{SignedTransaction: *signed}
81 | return ver, nil
82 | }
83 |
84 | func (ver *VersionedTransaction) marshal() []byte {
85 | switch ver.Version {
86 | case TxVersion:
87 | return NewEncoder().EncodeTransaction(&ver.SignedTransaction)
88 | default:
89 | panic(ver.Version)
90 | }
91 | }
92 |
93 | func (ver *VersionedTransaction) payloadMarshal() []byte {
94 | switch ver.Version {
95 | case TxVersion:
96 | signed := &SignedTransaction{Transaction: ver.Transaction}
97 | return NewEncoder().EncodeTransaction(signed)
98 | default:
99 | panic(ver.Version)
100 | }
101 | }
102 |
103 | func checkTxVersion(val []byte) bool {
104 | if len(val) < 4 {
105 | return false
106 | }
107 | v := append(magic, 0, TxVersion)
108 | return bytes.Equal(v, val[:4])
109 | }
110 |
--------------------------------------------------------------------------------
/common/withdrawal.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/MixinNetwork/mixin/crypto"
7 | )
8 |
9 | type WithdrawalData struct {
10 | Chain crypto.Hash
11 | AssetKey string
12 | Address string
13 | Tag string
14 | }
15 |
16 | func (w *WithdrawalData) Asset() *Asset {
17 | return &Asset{
18 | ChainId: w.Chain,
19 | AssetKey: w.AssetKey,
20 | }
21 | }
22 |
23 | func (tx *Transaction) validateWithdrawalSubmit(inputs map[string]*UTXO) error {
24 | for _, in := range inputs {
25 | if in.Type != OutputTypeScript {
26 | return fmt.Errorf("invalid utxo type %d", in.Type)
27 | }
28 | }
29 |
30 | if len(tx.Outputs) > 2 {
31 | return fmt.Errorf("invalid outputs count %d for withdrawal submit transaction", len(tx.Outputs))
32 | }
33 | if len(tx.Outputs) == 2 && tx.Outputs[1].Type != OutputTypeScript {
34 | return fmt.Errorf("invalid change type %d for withdrawal submit transaction", tx.Outputs[1].Type)
35 | }
36 |
37 | submit := tx.Outputs[0]
38 | if submit.Type != OutputTypeWithdrawalSubmit {
39 | return fmt.Errorf("invalid output type %d for withdrawal submit transaction", submit.Type)
40 | }
41 | if submit.Withdrawal == nil {
42 | return fmt.Errorf("invalid withdrawal submit data")
43 | }
44 |
45 | if err := submit.Withdrawal.Asset().Verify(); err != nil {
46 | return fmt.Errorf("invalid asset data %s", err.Error())
47 | }
48 | if id := submit.Withdrawal.Asset().AssetId(); id != tx.Asset {
49 | return fmt.Errorf("invalid asset %s %s", tx.Asset, id)
50 | }
51 |
52 | if len(submit.Keys) != 0 {
53 | return fmt.Errorf("invalid withdrawal submit keys %d", len(submit.Keys))
54 | }
55 | if len(submit.Script) != 0 {
56 | return fmt.Errorf("invalid withdrawal submit script %s", submit.Script)
57 | }
58 | if submit.Mask.HasValue() {
59 | return fmt.Errorf("invalid withdrawal submit mask %s", submit.Mask)
60 | }
61 |
62 | return nil
63 | }
64 |
65 | func (tx *Transaction) validateWithdrawalFuel(store DataStore, inputs map[string]*UTXO) error {
66 | for _, in := range inputs {
67 | if in.Type != OutputTypeScript {
68 | return fmt.Errorf("invalid utxo type %d", in.Type)
69 | }
70 | }
71 |
72 | if len(tx.Outputs) > 2 {
73 | return fmt.Errorf("invalid outputs count %d for withdrawal fuel transaction", len(tx.Outputs))
74 | }
75 | if len(tx.Outputs) == 2 && tx.Outputs[1].Type != OutputTypeScript {
76 | return fmt.Errorf("invalid change type %d for withdrawal fuel transaction", tx.Outputs[1].Type)
77 | }
78 |
79 | fuel := tx.Outputs[0]
80 | if fuel.Type != OutputTypeWithdrawalFuel {
81 | return fmt.Errorf("invalid output type %d for withdrawal fuel transaction", fuel.Type)
82 | }
83 |
84 | var hash crypto.Hash
85 | if len(tx.Extra) != len(hash) {
86 | return fmt.Errorf("invalid extra %d for withdrawal fuel transaction", len(tx.Extra))
87 | }
88 | copy(hash[:], tx.Extra)
89 | submit, _, err := store.ReadTransaction(hash)
90 | if err != nil {
91 | return err
92 | }
93 | if submit == nil {
94 | return fmt.Errorf("invalid withdrawal submit data")
95 | }
96 | withdrawal := submit.Outputs[0].Withdrawal
97 | if withdrawal == nil || submit.Outputs[0].Type != OutputTypeWithdrawalSubmit {
98 | return fmt.Errorf("invalid withdrawal submit data")
99 | }
100 | if id := withdrawal.Asset().FeeAssetId(); id != tx.Asset {
101 | return fmt.Errorf("invalid fee asset %s %s", tx.Asset, id)
102 | }
103 | return nil
104 | }
105 |
106 | func (tx *Transaction) validateWithdrawalClaim(store DataStore, inputs map[string]*UTXO, msg []byte) error {
107 | for _, in := range inputs {
108 | if in.Type != OutputTypeScript {
109 | return fmt.Errorf("invalid utxo type %d", in.Type)
110 | }
111 | }
112 |
113 | if tx.Asset != XINAssetId {
114 | return fmt.Errorf("invalid asset %s for withdrawal claim transaction", tx.Asset)
115 | }
116 | if len(tx.Outputs) > 2 {
117 | return fmt.Errorf("invalid outputs count %d for withdrawal claim transaction", len(tx.Outputs))
118 | }
119 | if len(tx.Outputs) == 2 && tx.Outputs[1].Type != OutputTypeScript {
120 | return fmt.Errorf("invalid change type %d for withdrawal claim transaction", tx.Outputs[1].Type)
121 | }
122 |
123 | claim := tx.Outputs[0]
124 | if claim.Type != OutputTypeWithdrawalClaim {
125 | return fmt.Errorf("invalid output type %d for withdrawal claim transaction", claim.Type)
126 | }
127 | if claim.Amount.Cmp(NewIntegerFromString(WithdrawalClaimFee)) < 0 {
128 | return fmt.Errorf("invalid output amount %s for withdrawal claim transaction", claim.Amount)
129 | }
130 |
131 | var hash crypto.Hash
132 | if len(tx.Extra) != len(hash) {
133 | return fmt.Errorf("invalid extra %d for withdrawal claim transaction", len(tx.Extra))
134 | }
135 | copy(hash[:], tx.Extra)
136 | submit, _, err := store.ReadTransaction(hash)
137 | if err != nil {
138 | return err
139 | }
140 | if submit == nil {
141 | return fmt.Errorf("invalid withdrawal submit data")
142 | }
143 | withdrawal := submit.Outputs[0].Withdrawal
144 | if withdrawal == nil || submit.Outputs[0].Type != OutputTypeWithdrawalSubmit {
145 | return fmt.Errorf("invalid withdrawal submit data")
146 | }
147 |
148 | var domainValid bool
149 | for _, d := range store.ReadDomains() {
150 | domainValid = true
151 | view := d.Account.PublicSpendKey.DeterministicHashDerive()
152 | for _, utxo := range inputs {
153 | for _, key := range utxo.Keys {
154 | ghost := crypto.ViewGhostOutputKey(&key, &view, &utxo.Mask, uint64(utxo.Index))
155 | valid := *ghost == d.Account.PublicSpendKey
156 | domainValid = domainValid && valid
157 | }
158 | }
159 | if domainValid {
160 | break
161 | }
162 | }
163 | if !domainValid {
164 | return fmt.Errorf("invalid domain signature for withdrawal claim")
165 | }
166 | return nil
167 | }
168 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | rm -rf ./build
4 | mkdir build
5 |
6 | git pull
7 | yarn install && yarn build
8 |
9 | SUM=`md5 -q build/index.html`
10 | mv build/index.html build/index.$SUM.html
11 |
12 | cp app.yaml build/app.yaml
13 | sed -i '' "s/index.html/index.$SUM.html/g" build/app.yaml || exit
14 | cd build && gcloud app deploy app.yaml -q
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "multisig-bot-react",
3 | "version": "1.1.1",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.21.1",
7 | "bot-api-js-client": "/Users/li/Javascript/bot-api-js-client",
8 | "js-base64": "^3.6.0",
9 | "node-polyglot": "^2.4.0",
10 | "node-sass": "^5.0.0",
11 | "react": "^17.0.2",
12 | "react-dom": "^17.0.2",
13 | "react-router-dom": "^5.2.0",
14 | "react-scripts": "4.0.3",
15 | "uuid": "^8.3.2",
16 | "web-vitals": "^1.0.1"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "__eject__": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": [
26 | "react-app",
27 | "react-app/jest"
28 | ]
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | },
42 | "devDependencies": {
43 | "@testing-library/jest-dom": "^5.11.4",
44 | "@testing-library/react": "^11.1.0",
45 | "@testing-library/user-event": "^12.1.10",
46 | "eslint-plugin-react-hooks": "^4.2.0",
47 | "husky": "^6.0.0",
48 | "lint-staged": "^10.5.4",
49 | "prettier": "^2.2.1"
50 | },
51 | "author": "Li Yuqing"
52 | }
53 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MixinNetwork/multisig-bot/d6e26a8442f4f1bb1911aaf3474c17d9c69b2b4a/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
23 | Multisig Wallet
24 |
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MixinNetwork/multisig-bot/d6e26a8442f4f1bb1911aaf3474c17d9c69b2b4a/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MixinNetwork/multisig-bot/d6e26a8442f4f1bb1911aaf3474c17d9c69b2b4a/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Multisig Wallet",
3 | "name": "Multisig Wallet Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#3D404B",
24 | "background_color": "#3D404B"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import "./App.css";
2 |
3 | import React from "react";
4 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
5 |
6 | import Auth from "./auth/view.js";
7 | import Home from "./home/view.js";
8 | import Guide from "./guide/view.js";
9 | import Asset from "./asset/view.js";
10 | import Transfer from "./transfer/view.js";
11 |
12 | export default function App() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 | import App from "./App";
3 |
4 | test("renders learn react link", () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/api/client.js:
--------------------------------------------------------------------------------
1 | import mixin from "bot-api-js-client";
2 |
3 | class Client {
4 | request(method, path, data, callback) {
5 | data = data || "";
6 | let uid = process.env.REACT_APP_CLIENT_ID;
7 | let sid = process.env.REACT_APP_SESSION_ID;
8 | let privateKey = process.env.REACT_APP_PRIVATE_KEY;
9 | return mixin.client.request(
10 | uid,
11 | sid,
12 | privateKey,
13 | method,
14 | path,
15 | data,
16 | callback
17 | ).then((resp) => {
18 | if (callback) {
19 | callback(resp.data)
20 | } else {
21 | return resp.data
22 | }
23 | });
24 | }
25 |
26 | requestByToken(method, path, data, accessToken, callback) {
27 | return mixin.client
28 | .requestByToken(method, path, data, accessToken, callback)
29 | .then((resp) => {
30 | return resp.data
31 | })
32 | .then((resp) => {
33 | let consumed = false;
34 | if (typeof callback === "function") {
35 | consumed = callback(resp);
36 | }
37 |
38 | if (!consumed) {
39 | if (resp && resp.error) {
40 | let clientId = process.env.REACT_APP_CLIENT_ID;
41 | switch (resp.error.code) {
42 | case 401:
43 | let verifier = mixin.util.challenge();
44 | window.location.replace(
45 | `https://mixin.one/oauth/authorize?client_id=${clientId}&scope=PROFILE:READ+ASSETS:READ+CONTACTS:READ&response_type=code&code_challenge=${verifier}`
46 | );
47 | return;
48 | default:
49 | break;
50 | }
51 | }
52 | }
53 |
54 | return resp;
55 | });
56 | }
57 | }
58 |
59 | export default new Client();
60 |
--------------------------------------------------------------------------------
/src/api/index.js:
--------------------------------------------------------------------------------
1 | import mixin from "bot-api-js-client";
2 |
3 | import client from "./client";
4 | import storage from "./storage";
5 |
6 | export const ApiGetChains = (force) => {
7 | if (!force) {
8 | let chains = storage.getChains();
9 | if (chains) {
10 | return new Promise((resolve) => {
11 | resolve(JSON.parse(chains));
12 | });
13 | }
14 | }
15 |
16 | return client.requestByToken("GET", "/network/chains", "").then((resp) => {
17 | if (resp.error) {
18 | return resp;
19 | }
20 |
21 | let chainMap = {};
22 | resp.data.forEach((chain) => {
23 | chainMap[chain.chain_id] = chain;
24 | });
25 | storage.setChains(chainMap);
26 | return chainMap;
27 | });
28 | };
29 |
30 | export const ApiGetMultisigAssets = () => {
31 | return client.requestByToken("GET", "/network/assets/multisig", "");
32 | };
33 | export const ApiGetMe = () => {
34 | return client.requestByToken("GET", "/me", "", storage.getToken());
35 | };
36 | export const ApiGetFriends = () => {
37 | return client.requestByToken("GET", "/friends", "", storage.getToken());
38 | };
39 | export const ApiGetAssets = () => {
40 | return client.requestByToken("GET", "/assets", "", storage.getToken());
41 | };
42 | export const ApiGetAsset = (id) => {
43 | return client.requestByToken("GET", `/assets/${id}`, "", storage.getToken());
44 | };
45 | export const ApiGetMultisigsOutputs = (ids, threshold, state, offset) => {
46 | let hash = mixin.util.hashMembers(ids);
47 | offset = offset || "";
48 | return client.requestByToken(
49 | "GET",
50 | `/multisigs/outputs?members=${hash}&threshold=${threshold}&state=${state}&offset=${offset}`,
51 | "",
52 | storage.getToken()
53 | );
54 | };
55 | export const ApiPostMultisigsRequests = (action, raw) => {
56 | let params = {
57 | action: action,
58 | raw: raw,
59 | };
60 | return client.requestByToken(
61 | "POST",
62 | "/multisigs/requests",
63 | params,
64 | storage.getToken(),
65 | );
66 | };
67 | export const ApiPostGhostKeys = (ids, index) => {
68 | let params = {
69 | receivers: ids,
70 | index: index,
71 | };
72 | return client.requestByToken(
73 | "POST",
74 | "/outputs",
75 | params,
76 | storage.getToken(),
77 | );
78 | };
79 | export const ApiGetConversation = (id) => {
80 | if (process.env.NODE_ENV !== "production") {
81 | id = process.env.REACT_APP_CONVERSATION_ID;
82 | }
83 | return client.requestByToken(
84 | "GET",
85 | `/conversations/${id}`,
86 | "",
87 | storage.getToken()
88 | );
89 | };
90 |
91 | export const ApiPostAuthenticate = (code) => {
92 | let clientId = process.env.REACT_APP_CLIENT_ID;
93 | let params = {
94 | client_id: clientId,
95 | code: code,
96 | code_verifier: storage.getVerifier(),
97 | };
98 |
99 | return client
100 | .requestByToken("POST", "/oauth/token", params, "")
101 | .then((resp) => {
102 | if (resp.error) {
103 | return resp;
104 | }
105 |
106 | if (
107 | resp.data.scope.indexOf("ASSETS:READ") < 0 ||
108 | resp.data.scope.indexOf("CONTACTS:READ") < 0
109 | ) {
110 | resp.error = { code: 403, description: "Access denied." };
111 | return resp;
112 | }
113 |
114 | storage.setToken(resp.data.access_token);
115 | return resp;
116 | });
117 | };
118 |
119 | export const ApiPostPayments = (params) => {
120 | return client.requestByToken("POST", "/payments", params, storage.getToken());
121 | };
122 |
123 | export const ApiPostUsersFetch = (ids) => {
124 | return client.requestByToken("POST", "/users/fetch", ids, storage.getToken());
125 | };
126 |
127 | export const ApiPostExternalProxy = (raw) => {
128 | let params = { method: "sendrawtransaction", params: [raw] };
129 | return client.requestByToken("POST", "/external/proxy", params, storage.getToken());
130 | };
131 |
132 | export const ApiGetCode = (id) => {
133 | return client.requestByToken("GET", `/codes/${id}`, "", storage.getToken());
134 | };
135 |
--------------------------------------------------------------------------------
/src/api/storage.js:
--------------------------------------------------------------------------------
1 | class Storage {
2 | setChains(chains) {
3 | window.localStorage.setItem("chains", JSON.stringify(chains));
4 | }
5 |
6 | getChains() {
7 | return window.localStorage.getItem("chains");
8 | }
9 |
10 | setChainsUpdatedAt() {
11 | window.localStorage.setItem("chains_updated_at", new Date());
12 | }
13 |
14 | getChainsUpdatedAt() {
15 | return window.localStorage.getItem("chains_updated_at");
16 | }
17 |
18 | setToken(token) {
19 | window.localStorage.setItem("token", token);
20 | }
21 |
22 | getToken() {
23 | return window.localStorage.getItem("token") || "";
24 | }
25 |
26 | getVerifier() {
27 | return window.localStorage.getItem("verifier");
28 | }
29 |
30 | getSelectedAssets() {
31 | let selected = window.localStorage.getItem("selected_assets");
32 | if (!selected) {
33 | return {
34 | "c6d0c728-2624-429b-8e0d-d9d19b6592fa": 0,
35 | "c94ac88f-4671-3976-b60a-09064f1811e8": 0,
36 | };
37 | }
38 | return JSON.parse(selected);
39 | }
40 |
41 | setSelectedAssets(assets) {
42 | window.localStorage.setItem("selected_assets", JSON.stringify(assets));
43 | }
44 | }
45 |
46 | export default new Storage();
47 |
--------------------------------------------------------------------------------
/src/api/util.js:
--------------------------------------------------------------------------------
1 | class Util {
2 | parseThreshold(name) {
3 | name = name || "";
4 | let parts = name.split("^");
5 | if (parts.length !== 2) {
6 | return -1;
7 | }
8 | let t = parseInt(parts[1]);
9 | return t ? t : -1;
10 | }
11 | }
12 |
13 | export default new Util();
14 |
--------------------------------------------------------------------------------
/src/asset/contacts.js:
--------------------------------------------------------------------------------
1 | import styles from "./contacts.module.scss";
2 | import React, { useState, useEffect } from "react";
3 | import { Link } from "react-router-dom";
4 |
5 | import { ApiGetFriends } from "../api";
6 | import { ReactComponent as CloseIcon } from "../statics/images/ic_close.svg";
7 | import { ReactComponent as SearchIcon } from "../statics/images/ic_search.svg";
8 |
9 | function Contacts(props) {
10 | const i18n = window.i18n;
11 |
12 | const [text, setText] = useState("");
13 | const [contacts, setContacts] = useState([]);
14 |
15 | useEffect(() => {
16 | ApiGetFriends().then((resp) => {
17 | if (resp.error) {
18 | return;
19 | }
20 |
21 | setContacts(resp.data);
22 | });
23 | });
24 |
25 | function filteredContacts() {
26 | let search = text.toLowerCase();
27 | if (search.length > 0) {
28 | let filtered = contacts.filter((contact) => {
29 | return `${contact.identity_number}` === search;
30 | });
31 | if (filtered <= 0) {
32 | filtered = contacts.filter((contact) => {
33 | return contact.full_name.toLowerCase() === search;
34 | });
35 | }
36 | if (filtered <= 0) {
37 | filtered = contacts.filter((contact) => {
38 | return `${contact.identity_number}`.includes(search);
39 | });
40 | }
41 | if (filtered <= 0) {
42 | filtered = contacts.filter((contact) => {
43 | return contact.full_name.toLowerCase().includes(search);
44 | });
45 | }
46 | return filtered;
47 | }
48 | return contacts;
49 | }
50 |
51 | let contactList = filteredContacts().map((contact) => {
52 | let avatar =
53 | contact.avatar_url.length > 0 ? (
54 |
59 | ) : (
60 | {contact.full_name.slice(0, 1)}
61 | );
62 | return (
63 |
64 |
65 | {avatar}
66 |
67 | {contact.full_name}
68 |
{contact.identity_number}
69 |
70 |
71 |
72 | );
73 | });
74 |
75 | return (
76 |
77 |
78 |
86 |
87 |
88 | setText(e.target.value)}
93 | />
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | export default Contacts;
104 |
--------------------------------------------------------------------------------
/src/asset/contacts.module.scss:
--------------------------------------------------------------------------------
1 | .contacts {
2 | background: rgba(0, 0, 0, 0.6);
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | z-index: 999;
7 | width: 100%;
8 | height: 100%;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: flex-end;
12 |
13 | header {
14 | display: flex;
15 | margin-bottom: 2rem;
16 | .title {
17 | flex-grow: 1;
18 | }
19 | }
20 |
21 | .container {
22 | background: white;
23 | border-radius: 1.3rem 1.3rem 0 0;
24 | padding: 2.4rem 2rem 3rem;
25 | height: 90%;
26 | display: flex;
27 | flex-direction: column;
28 | }
29 |
30 | .search {
31 | background: #f5f7fa;
32 | border-radius: 2rem;
33 | margin-bottom: 1.6rem;
34 | padding: 1.2rem 1.6rem;
35 | display: flex;
36 | align-items: center;
37 | input {
38 | background: #f5f7fa;
39 | border: none;
40 | font-size: 1.6rem;
41 | padding: 0 1.2rem;
42 | flex-grow: 1;
43 | }
44 | }
45 |
46 | main {
47 | flex-grow: 1;
48 | overflow: auto;
49 | }
50 |
51 | .item {
52 | display: flex;
53 | align-items: center;
54 | padding: 1.2rem 0;
55 | }
56 |
57 | .info {
58 | padding-left: 1.6rem;
59 | }
60 |
61 | .id {
62 | font-size: 1.4rem;
63 | color: #b8bdc7;
64 | margin-top: 0.4rem;
65 | }
66 |
67 | .avatar {
68 | border-radius: 2.1rem;
69 | width: 4.2rem;
70 | height: 4.2rem;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/asset/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 | import React, { Component } from "react";
3 | import Decimal from "decimal.js";
4 | import mixin from "bot-api-js-client";
5 | import { Link, Redirect } from "react-router-dom";
6 |
7 | import {
8 | ApiGetAsset,
9 | ApiGetChains,
10 | ApiGetMultisigsOutputs,
11 | ApiGetConversation,
12 | } from "../api";
13 | import util from "../api/util.js";
14 | import Header from "../components/header.js";
15 | import AssetIcon from "../components/cover.js";
16 | import Loading from "../components/loading.js";
17 | import Modal from "./modal.js";
18 | import Contacts from "./contacts.js";
19 | import background from "../statics/images/bg.png";
20 | import { ReactComponent as TransactionIcon } from "../statics/images/ic_transaction.svg";
21 |
22 | class Index extends Component {
23 | constructor(props) {
24 | super(props);
25 |
26 | this.state = {
27 | assetId: props.match.params.id,
28 | asset: {},
29 | outputs: [],
30 | receive: false,
31 | send: false,
32 | loading: true,
33 | requesting: true,
34 | };
35 |
36 | this.handleReceive = this.handleReceive.bind(this);
37 | this.handleSend = this.handleSend.bind(this);
38 | }
39 |
40 | handleReceive(b) {
41 | this.setState({ receive: b });
42 | }
43 |
44 | handleSend(b) {
45 | this.setState({ send: b });
46 | }
47 |
48 | async loadConversation() {
49 | let conversation = await ApiGetConversation(mixin.util.conversationId());
50 | if (conversation.data) {
51 | return conversation.data;
52 | }
53 | if (conversation.error.code === 404) {
54 | return; // TODO should handle 404
55 | }
56 | return this.loadConversation();
57 | }
58 |
59 | async loadAsset() {
60 | let asset = await ApiGetAsset(this.state.assetId);
61 | if (asset.data) {
62 | return asset.data;
63 | }
64 | return this.loadAsset();
65 | }
66 |
67 | async loadChains() {
68 | let chains = await ApiGetChains();
69 | if (!chains.error) {
70 | return chains;
71 | }
72 | return this.loadChains();
73 | }
74 |
75 | async loadMultisigsOutputs(participants, threshold, offset, utxo) {
76 | let outputs = await ApiGetMultisigsOutputs(
77 | participants,
78 | threshold,
79 | "",
80 | offset
81 | );
82 | if (outputs.data) {
83 | utxo.push(...outputs.data);
84 | if (outputs.data.length < 500) {
85 | return utxo;
86 | }
87 | let output = outputs.data[outputs.data.length - 1];
88 | if (output) {
89 | offset = output.updated_at;
90 | }
91 | }
92 | this.loadMultisigsOutputs(participants, threshold, offset, utxo);
93 | }
94 |
95 | async loadFullData() {
96 | let that = this;
97 |
98 | let chains = await that.loadChains();
99 | let asset = await that.loadAsset();
100 | asset.chain = chains[asset.chain_id];
101 | asset.balance = "0";
102 | asset.value = "0";
103 | this.setState({
104 | asset: asset,
105 | loading: false,
106 | });
107 |
108 | let conversation = await that.loadConversation();
109 | let participants = [];
110 | conversation.participants.forEach((p) => {
111 | if (
112 | process.env.REACT_APP_CLIENT_ID !== p.user_id &&
113 | "37e040ec-df91-47a7-982e-0e118932fa8b" !== p.user_id
114 | ) {
115 | participants.push(p.user_id);
116 | }
117 | });
118 | let transactions = [];
119 | let outputs = await that.loadMultisigsOutputs(
120 | participants,
121 | util.parseThreshold(conversation.name),
122 | "",
123 | []
124 | );
125 | let balance = outputs.reduce((a, c) => {
126 | if (c.asset_id === that.state.assetId) {
127 | if (transactions.length < 500) {
128 | transactions.push(c); // Only list latest 500 transactions
129 | }
130 | if (c.state !== "spent") {
131 | return a.plus(c.amount);
132 | }
133 | }
134 | return a;
135 | }, new Decimal("0"));
136 | asset.balance = balance.toFixed();
137 | asset.value = new Decimal(
138 | balance.times(asset.price_usd).toFixed(8)
139 | ).toFixed();
140 | this.setState({
141 | asset: asset,
142 | outputs: transactions,
143 | requesting: false,
144 | });
145 | }
146 |
147 | componentDidMount() {
148 | this.loadFullData();
149 | }
150 |
151 | render() {
152 | const i18n = window.i18n;
153 | let state = this.state;
154 |
155 | if (state.loading) {
156 | return ;
157 | }
158 |
159 | if (state.guide) {
160 | return ;
161 | }
162 |
163 | let blank = (
164 |
165 |
166 |
{i18n.t("asset.blank")}
167 |
168 | );
169 |
170 | let hint = 100;
171 | let transactionList = state.outputs.sort((a, b) => {
172 | if ((new Date(a.updated_at)) > (new Date(b.updated_at))) {
173 | return -1;
174 | }
175 | if ((new Date(a.updated_at)) < (new Date(b.updated_at))) {
176 | return 1;
177 | }
178 | return 0;
179 | }).map((o) => {
180 | let created = new Date(o.updated_at);
181 | let divide = hint !== created.getDate();
182 | let style = o.state === "spent" ? "red" : "green" ;
183 | hint = created.getDate();
184 | return (
185 |
186 | {divide && (
187 |
188 | {`${
189 | created.getMonth() + 1
190 | }/${created.getDate()}/${created.getFullYear()}`}
191 |
192 | )}
193 |
194 |
195 |
196 |
{o.memo || i18n.t("asset.memo")}
197 | { o.state === "signed" &&
{ i18n.t("asset.signed") }
}
198 |
199 |
200 | { o.state === "spent" ? "-" : "+" }
201 | { o.amount }
202 |
203 |
{state.asset.symbol}
204 |
205 |
206 |
207 | );
208 | });
209 |
210 | let transactions = (
211 |
212 |
{i18n.t("asset.transactions")}
213 |
214 |
215 | );
216 |
217 | return (
218 |
222 |
223 |
224 |
225 |
226 | {state.asset.balance} {state.asset.symbol}
227 |
228 |
≈ ${state.asset.value}
229 |
230 |
this.handleSend(true)}>
231 | {i18n.t("asset.action.send")}
232 |
233 |
234 |
this.handleReceive(true)}>
235 | {i18n.t("asset.action.receive")}
236 |
237 |
238 |
239 | {state.outputs.length === 0 && !state.requesting && blank}
240 | {state.outputs.length === 0 && state.requesting && (
241 |
242 | { i18n.t("loading") }
243 |
244 | )}
245 | {state.outputs.length > 0 && transactions}
246 | {state.receive && (
247 |
248 | )}
249 | {state.send && (
250 |
251 | )}
252 |
253 | );
254 | }
255 | }
256 |
257 | export default Index;
258 |
--------------------------------------------------------------------------------
/src/asset/index.module.scss:
--------------------------------------------------------------------------------
1 | .asset {
2 | background-position: top;
3 | background-size: 100% auto;
4 | background-repeat: no-repeat;
5 | min-height: 100vh;
6 | padding: 0 1rem;
7 | display: flex;
8 | flex-direction: column;
9 | }
10 | .info {
11 | background: white;
12 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
13 | border-radius: 1.6rem;
14 | padding: 4rem 4.2rem 1.6rem;
15 | margin: 0 0 1rem;
16 | display: flex;
17 | flex-direction: column;
18 | align-items: center;
19 | .balance {
20 | font-size: 3.2rem;
21 | font-weight: 600;
22 | margin: 0.6rem 0;
23 | span {
24 | font-size: 1.6rem;
25 | font-weight: normal;
26 | }
27 | }
28 | .value {
29 | font-size: 1.4rem;
30 | color: #b8bdc7;
31 | margin-bottom: 1.6rem;
32 | }
33 | }
34 | .icon {
35 | margin-bottom: 0.8rem;
36 | }
37 | .actions {
38 | background: #f5f7fa;
39 | border-radius: 1.4rem;
40 | font-weight: 500;
41 | padding: 1.4rem 0 1.5rem;
42 | display: flex;
43 | justify-content: space-evenly;
44 | width: 100%;
45 | }
46 | .divide {
47 | width: 2px;
48 | background: rgba(0, 0, 0, 0.04);
49 | }
50 | .blank {
51 | background: white;
52 | border-radius: 1.6rem 1.6rem 0 0;
53 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
54 | color: #b8bdc7;
55 | flex-grow: 1;
56 | display: flex;
57 | flex-direction: column;
58 | justify-content: center;
59 | align-items: center;
60 | padding-bottom: 6rem;
61 | }
62 | .requesting {
63 | background: white;
64 | border-radius: 1.6rem 1.6rem 0 0;
65 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
66 | display: flex;
67 | flex-grow: 1;
68 | flex-direction: column;
69 | align-items: center;
70 | padding: 2rem 0 6rem;
71 | }
72 | .text {
73 | font-size: 1.4rem;
74 | margin-top: 2.4rem;
75 | }
76 | .transactions {
77 | background: white;
78 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
79 | border-radius: 1.6rem 1.6rem 0 0;
80 | flex-grow: 1;
81 | padding: 2rem 1.6rem;
82 | header {
83 | font-size: 1.4rem;
84 | margin-bottom: 2rem;
85 | }
86 | .hint {
87 | color: #b8bdc7;
88 | font-size: 1.4rem;
89 | margin-bottom: 2rem;
90 | }
91 | .item {
92 | display: flex;
93 | flex-direction: row;
94 | justify-content: flex-end;
95 | align-items: flex-start;
96 | overflow: hidden;
97 | margin-bottom: 2.8rem;
98 | &.signed {
99 | color: #B8BDC7;
100 | }
101 | }
102 | .memo {
103 | flex-grow: 1;
104 | overflow: hidden;
105 | padding-right: 1.6rem;
106 | .state {
107 | font-size: 1.4rem;
108 | }
109 | }
110 | .amount {
111 | padding: 0 0.5rem 0 2rem;
112 | }
113 | .symbol {
114 | color: #333;
115 | font-size: 1.4rem;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/asset/modal.js:
--------------------------------------------------------------------------------
1 | import styles from "./modal.module.scss";
2 | import { Link } from "react-router-dom";
3 |
4 | import { ReactComponent as CloseIcon } from "../statics/images/ic_close.svg";
5 | import { ReactComponent as RightIcon } from "../statics/images/ic_right.svg";
6 | import { ReactComponent as WalletIcon } from "../statics/images/ic_wallet.svg";
7 | import { ReactComponent as LinkIcon } from "../statics/images/ic_link.svg";
8 |
9 | function Modal(props) {
10 | const i18n = window.i18n;
11 |
12 | return (
13 |
14 |
15 |
23 |
24 |
28 |
29 | {i18n.t("asset.modal.wallet")}
30 |
31 |
32 |
36 |
37 | {i18n.t("asset.modal.recipient")}
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | export default Modal;
47 |
--------------------------------------------------------------------------------
/src/asset/modal.module.scss:
--------------------------------------------------------------------------------
1 | .modal {
2 | background: rgba(0, 0, 0, 0.6);
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | z-index: 999;
7 | width: 100%;
8 | height: 100%;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: flex-end;
12 |
13 | .container {
14 | background: white;
15 | border-radius: 1.3rem 1.3rem 0 0;
16 | padding: 2.4rem 2rem 8rem;
17 | }
18 |
19 | header {
20 | display: flex;
21 | font-weight: bold;
22 | margin-bottom: 3rem;
23 | .title {
24 | flex-grow: 1;
25 | }
26 | }
27 |
28 | .action {
29 | background: #f5f7fa;
30 | border-radius: 1.2rem;
31 | display: flex;
32 | align-items: center;
33 | padding: 2rem 2.4rem;
34 | margin: 1rem 0;
35 | }
36 |
37 | .text {
38 | flex-grow: 1;
39 | padding: 0 1.6rem;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/asset/view.js:
--------------------------------------------------------------------------------
1 | import Index from "./index.js";
2 |
3 | const view = {
4 | Index: Index,
5 | };
6 |
7 | export default view;
8 |
--------------------------------------------------------------------------------
/src/auth/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Redirect, useLocation } from "react-router-dom";
3 |
4 | import Loading from "../components/loading.js";
5 | import { ApiPostAuthenticate } from "../api";
6 |
7 | function Index() {
8 | const [state, setState] = useState({ loading: true });
9 | const searchParams = new URLSearchParams(useLocation().search);
10 |
11 | useEffect(() => {
12 | ApiPostAuthenticate(searchParams.get("code")).then((resp) => {
13 | if (!resp.error) {
14 | setState({ loading: false });
15 | }
16 | });
17 | });
18 |
19 | if (state.loading) {
20 | return (
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | return ;
28 | }
29 |
30 | export default Index;
31 |
--------------------------------------------------------------------------------
/src/auth/view.js:
--------------------------------------------------------------------------------
1 | import Index from "./index.js";
2 |
3 | const view = {
4 | Index: Index,
5 | };
6 |
7 | export default view;
8 |
--------------------------------------------------------------------------------
/src/components/cover.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 |
3 | function AssetIcon(props) {
4 | let asset = props.asset;
5 |
6 | return (
7 |
8 |

9 |

14 |
15 | );
16 | }
17 |
18 | export default AssetIcon;
19 |
--------------------------------------------------------------------------------
/src/components/header.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 | import { Link } from "react-router-dom";
3 |
4 | import { ReactComponent as LeftIcon } from "../statics/images/ic_left.svg";
5 |
6 | function Header(props) {
7 | const i18n = window.i18n;
8 |
9 | return (
10 |
11 | { props.icon !== "disable" && }
12 | { i18n.t(props.name) }
13 |
14 | );
15 | }
16 |
17 | export default Header;
18 |
--------------------------------------------------------------------------------
/src/components/index.module.scss:
--------------------------------------------------------------------------------
1 | .loading {
2 | min-height: 100vh;
3 | display: flex;
4 | flex-direction: column;
5 | justify-content: center;
6 | align-items: center;
7 | padding-bottom: 16rem;
8 |
9 | svg {
10 | width: 3.6rem;
11 | }
12 | .text {
13 | margin-top: 0.8rem;
14 | }
15 | }
16 |
17 | .icon {
18 | width: 5rem;
19 | position: relative;
20 | .chain {
21 | border: 2px solid white;
22 | border-radius: 0.8rem;
23 | width: 1.6rem;
24 | position: absolute;
25 | top: 60%;
26 | left: 0%;
27 | z-index: 99;
28 | }
29 | }
30 |
31 | .header {
32 | color: white;
33 | font-size: 1.8rem;
34 | display: flex;
35 | align-items: center;
36 | margin: .8rem 0 3rem;
37 | padding: 0 1.4rem;
38 | svg {
39 | margin-right: 1.6rem;
40 | }
41 | &.black {
42 | color: #333;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/loading.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 |
3 | import { ReactComponent as LoadingSpin } from "../statics/images/loading_spin.svg";
4 |
5 | function Loading() {
6 | const i18n = window.i18n;
7 |
8 | return (
9 |
10 |
11 |
{i18n.t("loading")}
12 |
13 | );
14 | }
15 |
16 | export default Loading;
17 |
--------------------------------------------------------------------------------
/src/guide/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 |
3 | import Header from "../components/header.js";
4 | import { ReactComponent as GuideIcon } from "../statics/images/ic_guide.svg";
5 |
6 | function Index() {
7 | const i18n = window.i18n;
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 | );
21 | }
22 |
23 | export default Index;
24 |
--------------------------------------------------------------------------------
/src/guide/index.module.scss:
--------------------------------------------------------------------------------
1 | .guide {
2 | min-height: 100vh;
3 | display: flex;
4 | flex-direction: column;
5 | padding: 0 1rem 20rem;
6 |
7 | main {
8 | flex-grow: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | padding: 0 1.4rem;
14 | }
15 |
16 | .icon {
17 | margin-bottom: 6rem;
18 | }
19 |
20 | .body {
21 | padding-left: 1rem;
22 | li {
23 | margin-bottom: 1rem;
24 | position: relative;
25 | &:before {
26 | content: "";
27 | position: absolute;
28 | left: -1rem;
29 | top: 0.8rem;
30 | background: #333;
31 | display: inline-block;
32 | width: 0.4rem;
33 | height: 0.4rem;
34 | border-radius: 0.2rem;
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/guide/view.js:
--------------------------------------------------------------------------------
1 | import Index from "./index.js";
2 |
3 | const view = {
4 | Index: Index,
5 | };
6 |
7 | export default view;
8 |
--------------------------------------------------------------------------------
/src/home/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss"; // styles is create-react-app
2 | import React, { Component } from "react";
3 | import Decimal from "decimal.js";
4 | import mixin from "bot-api-js-client";
5 | import { Link, Redirect } from "react-router-dom";
6 |
7 | import {
8 | ApiGetChains,
9 | ApiGetConversation,
10 | ApiGetMultisigsOutputs,
11 | ApiGetAsset,
12 | } from "../api";
13 | import util from "../api/util.js";
14 | import storage from "../api/storage.js";
15 | import AssetIcon from "../components/cover.js";
16 | import Header from "../components/header.js";
17 | import Loading from "../components/loading.js";
18 | import background from "../statics/images/bg_texture.png";
19 | import { ReactComponent as SettingIcon } from "../statics/images/ic_setting.svg";
20 | import Modal from "./modal.js";
21 |
22 |
23 | class Index extends Component {
24 | constructor(props) {
25 | super(props);
26 |
27 | this.state = {
28 | balanceBTC: 0,
29 | balanceUSD: 0,
30 | assets: [],
31 | participantsCount: 0,
32 | threshold: 0,
33 | modal: false,
34 | loading: true,
35 | guide: false,
36 | };
37 | }
38 |
39 | handleModal = (b) => {
40 | this.setState({ modal: b });
41 | }
42 |
43 | async loadFullData() {
44 | let conversation = await this.loadConversation();
45 | let threshold = util.parseThreshold(conversation.name);
46 | if (
47 | !conversation ||
48 | conversation.category !== "GROUP" ||
49 | threshold < 1 ||
50 | conversation.participants.length < 3
51 | ) {
52 | this.setState({
53 | loading: false,
54 | guide: true,
55 | });
56 | return;
57 | }
58 | let participantIds = [];
59 | conversation.participants.forEach((p) => {
60 | // skip current and old multisig bot
61 | if (
62 | process.env.REACT_APP_CLIENT_ID !== p.user_id &&
63 | "37e040ec-df91-47a7-982e-0e118932fa8b" !== p.user_id // development bot id
64 | ) {
65 | participantIds.push(p.user_id);
66 | }
67 | });
68 | this.setState({
69 | participantsCount: participantIds.length,
70 | threshold: threshold,
71 | loading: false,
72 | });
73 |
74 | // multisig output assets will always display
75 | Promise.all([
76 | this.loadMultisigsOutputs(
77 | participantIds,
78 | threshold,
79 | "unspent",
80 | "",
81 | []
82 | ),
83 | this.loadMultisigsOutputs(
84 | participantIds,
85 | threshold,
86 | "signed",
87 | "",
88 | []
89 | ),
90 | this.loadChains()
91 | ]).then((values) => {
92 | let outputs = values[0];
93 | let signed = values[1];
94 | let chains = values[2];
95 |
96 | outputs.push(...signed);
97 | let outputSet = {};
98 | let assetSet = storage.getSelectedAssets();
99 | let assetStateSet = {};
100 | outputs.forEach(output => {
101 | if (outputSet[output.utxo_id]) {
102 | return;
103 | }
104 | if (!assetSet[output.asset_id]) {
105 | assetSet[output.asset_id] = 0;
106 | }
107 | assetSet[output.asset_id] = new Decimal(
108 | assetSet[output.asset_id]
109 | ).plus(output.amount);
110 | if (output.state === "signed") {
111 | assetStateSet[output.asset_id] = output.state;
112 | }
113 | outputSet[output.utxo_id] = true;
114 | });
115 |
116 | let assetIds = Object.keys(assetSet);
117 | this.loadAssets(assetIds, 0, []).then(assets => {
118 | let balanceBTC = this.state.balanceBTC;
119 | let balanceUSD = this.state.balanceUSD;
120 | for (let i = 0; i < assets.length; i++) {
121 | assets[i].state = assetStateSet[assets[i].asset_id] || "unspent";
122 | assets[i].balance = new Decimal(assetSet[assets[i].asset_id]).toFixed();
123 | assets[i].value = new Decimal(
124 | new Decimal(assets[i].balance).times(assets[i].price_usd).toFixed(8)
125 | ).toFixed();
126 | assets[i].change_usd = new Decimal(assets[i].change_usd).toFixed(2);
127 | assets[i].price_usd = new Decimal(assets[i].price_usd).toFixed();
128 | if (new Decimal(assets[i].price_usd).cmp(1) > 0) {
129 | assets[i].price_usd = new Decimal(
130 | new Decimal(assets[i].price_usd).toFixed(2)
131 | ).toFixed();
132 | }
133 | if (!chains[assets[i].chain_id]) {
134 | this.loadChains(true);
135 | }
136 | assets[i].chain = chains[assets[i].chain_id];
137 | balanceBTC = new Decimal(assets[i].balance)
138 | .times(assets[i].price_btc)
139 | .plus(balanceBTC);
140 | balanceUSD = new Decimal(assets[i].value).plus(balanceUSD);
141 | }
142 | balanceBTC = balanceBTC.toFixed(8);
143 | balanceBTC = new Decimal(balanceBTC).toFixed();
144 | balanceUSD = balanceUSD.toFixed(2);
145 | balanceUSD = new Decimal(balanceUSD).toFixed();
146 | assets = assets.sort((a, b) => {
147 | let value = new Decimal(a.value).cmp(b.value);
148 | if (value !== 0) {
149 | return -value;
150 | }
151 | let balance = new Decimal(a.balance).cmp(b.balance);
152 | if (balance !== 0) {
153 | return -balance;
154 | }
155 | return -new Decimal(a.price_usd).cmp(b.price_usd);
156 | });
157 | this.setState({
158 | balanceBTC: balanceBTC,
159 | balanceUSD: balanceUSD,
160 | assets: assets,
161 | participantsCount: participantIds.length,
162 | threshold: threshold,
163 | loading: false,
164 | });
165 | });
166 | });
167 | }
168 |
169 | async loadConversation() {
170 | let conversation = await ApiGetConversation(mixin.util.conversationId());
171 | if (conversation.data) {
172 | return conversation.data;
173 | }
174 | if (conversation.error.code === 404) {
175 | return;
176 | }
177 | return this.loadConversation();
178 | }
179 |
180 | async loadMultisigsOutputs(participants, threshold, state, offset, utxo) {
181 | let outputs = await ApiGetMultisigsOutputs(
182 | participants,
183 | threshold,
184 | state,
185 | offset,
186 | );
187 | if (outputs.data) {
188 | utxo.push(...outputs.data);
189 | if (outputs.data.length < 500) {
190 | return utxo;
191 | }
192 | let output = outputs.data[outputs.data.length - 1];
193 | if (output) {
194 | offset = output.updated_at;
195 | }
196 | }
197 | this.loadMultisigsOutputs(participants, threshold, offset, utxo);
198 | }
199 |
200 | async loadChains(force=false) {
201 | let chains = await ApiGetChains(force);
202 | if (!chains.error) {
203 | return chains;
204 | }
205 | return this.loadChains();
206 | }
207 |
208 | async loadAssets(ids, offset, output) {
209 | if (ids.length === offset) {
210 | return output;
211 | }
212 | let asset = await ApiGetAsset(ids[offset]);
213 | if (asset.data) {
214 | output.push(asset.data);
215 | offset += 1;
216 | }
217 | return this.loadAssets(ids, offset, output);
218 | }
219 |
220 | componentDidMount() {
221 | this.loadFullData();
222 | }
223 |
224 | componentDidUpdate(prevProps, prevState) {
225 | if (prevState.modal && !this.state.modal) {
226 | this.loadFullData();
227 | }
228 | }
229 |
230 | render() {
231 | let t = window.i18n.tt();
232 | let state = this.state;
233 |
234 | if (state.loading) {
235 | return ;
236 | }
237 |
238 | if (state.guide) {
239 | return ;
240 | }
241 |
242 | let assets = state.assets.map((asset) => {
243 | return (
244 |
245 |
246 |
247 |
248 | {asset.balance} {asset.symbol}
249 | { asset.state === 'signed' &&
(OTW) }
250 |
≈ ${asset.value}
251 |
252 |
253 |
= 0 ? styles.red : styles.green
256 | }
257 | >
258 | {asset.change_usd}%
259 |
260 |
${asset.price_usd}
261 |
262 |
263 |
264 | );
265 | });
266 |
267 | return (
268 |
272 |
273 |
274 |
275 | {state.balanceBTC} BTC
276 |
277 |
≈ ${state.balanceUSD}
278 |
279 |
280 |
281 | {t("home.assets")}
282 | this.handleModal(true)} />
283 |
284 |
285 | {state.assets.length === 0 &&
286 | {t("loading")}
287 |
}
288 | {state.assets.length > 0 && }
289 |
290 |
291 | {state.modal &&
}
292 |
293 | );
294 | }
295 | }
296 |
297 | export default Index;
298 |
--------------------------------------------------------------------------------
/src/home/index.module.scss:
--------------------------------------------------------------------------------
1 | .home {
2 | background-position: top;
3 | background-size: 100% auto;
4 | background-repeat: no-repeat;
5 | min-height: 100vh;
6 | padding: 0 1rem;
7 | display: flex;
8 | flex-direction: column;
9 | }
10 | .balance {
11 | color: white;
12 | text-align: center;
13 | padding: 3.6rem 0 4.8rem;
14 | .btc {
15 | font-size: 4rem;
16 | margin-bottom: 1.6rem;
17 | span {
18 | font-size: 1.6rem;
19 | }
20 | }
21 | .usd {
22 | color: rgba(255, 255, 255, 0.5);
23 | font-size: 2rem;
24 | }
25 | }
26 | .main {
27 | background: white;
28 | border-radius: 1.2rem 1.2rem 0 0;
29 | padding: 3rem 1.6rem;
30 | flex-grow: 1;
31 | header {
32 | display: flex;
33 | margin-bottom: 3rem;
34 | .title {
35 | flex-grow: 1;
36 | }
37 | }
38 | }
39 | .loading {
40 | text-align: center;
41 | }
42 | .item {
43 | color: #333;
44 | display: flex;
45 | align-items: center;
46 | justify-content: center;
47 | margin-bottom: 3.2rem;
48 | .price {
49 | color: #b8bdc7;
50 | font-size: 1.4rem;
51 | margin-top: 0.4rem;
52 | }
53 | .red {
54 | color: #e57874;
55 | }
56 | .green {
57 | color: #5dbc7a;
58 | }
59 | .info {
60 | flex-grow: 1;
61 | padding-left: 1.6rem;
62 | span {
63 | color: #5dbc7a;
64 | }
65 | }
66 | .value {
67 | text-align: right;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/home/modal.js:
--------------------------------------------------------------------------------
1 | import styles from "./modal.module.scss";
2 | import React, { Component } from "react";
3 | import Decimal from "decimal.js";
4 |
5 | import { ApiGetChains, ApiGetMultisigAssets, ApiGetAssets } from "../api";
6 | import storage from "../api/storage.js";
7 | import AssetIcon from "../components/cover.js";
8 | import { ReactComponent as CloseIcon } from "../statics/images/ic_close.svg";
9 | import { ReactComponent as SearchIcon } from "../statics/images/ic_search.svg";
10 | import { ReactComponent as SelectIcon } from "../statics/images/ic_select.svg";
11 | import { ReactComponent as SelectedIcon } from "../statics/images/ic_selected.svg";
12 |
13 | class Modal extends Component {
14 | constructor(props) {
15 | super(props);
16 |
17 | this.state = {
18 | text: "",
19 | assets: [],
20 | selectedAssets: {},
21 | };
22 |
23 | this.handleChange = this.handleChange.bind(this);
24 | this.toggleSelect = this.toggleSelect.bind(this);
25 | }
26 |
27 | handleChange(e) {
28 | const { name, value } = e.target;
29 | this.setState({
30 | [name]: value,
31 | });
32 | }
33 |
34 | filteredAssets() {
35 | let state = this.state;
36 | let text = state.text.toLowerCase();
37 | if (text.length > 0) {
38 | let assets = state.assets.filter((asset) => {
39 | return asset.symbol.toLowerCase() === text;
40 | });
41 | if (assets.length > 0) {
42 | return assets;
43 | }
44 | assets = state.assets.filter((asset) => {
45 | return asset.name.toLowerCase() === text;
46 | });
47 | if (assets.length > 0) {
48 | return assets;
49 | }
50 | assets = state.assets.filter((asset) => {
51 | return asset.symbol.toLowerCase().includes(text);
52 | });
53 | if (assets.length > 0) {
54 | return assets;
55 | }
56 | assets = state.assets.filter((asset) => {
57 | return asset.name.toLowerCase().includes(text);
58 | });
59 | return assets;
60 | }
61 | return state.assets;
62 | }
63 |
64 | toggleSelect(asset) {
65 | let assets = this.state.selectedAssets;
66 | if (this.state.selectedAssets[asset] === 0) {
67 | delete assets[asset];
68 | } else {
69 | assets[asset] = 0;
70 | }
71 | this.setState({ selectedAssets: assets }, () => {
72 | storage.setSelectedAssets(assets);
73 | });
74 | }
75 |
76 | async loadChains() {
77 | let chains = await ApiGetChains();
78 | if (!chains.error) {
79 | return chains;
80 | }
81 | return this.loadChains();
82 | }
83 |
84 | async loadMultisigAssets() {
85 | let assets = await ApiGetMultisigAssets();
86 | if (assets.data) {
87 | return assets.data;
88 | }
89 | return this.loadMultisigAssets();
90 | }
91 |
92 | async loadAssets() {
93 | let assets = await ApiGetAssets();
94 | if (assets.data) {
95 | return assets.data;
96 | }
97 | return this.loadAssets();
98 | }
99 |
100 | async loadFullData() {
101 | let chains = await this.loadChains();
102 | let multisigAssets = await this.loadMultisigAssets();
103 | let multisigAssetSet = {};
104 | for (let i = 0; i < multisigAssets.length; i++) {
105 | multisigAssetSet[multisigAssets[i].asset_id] = true;
106 | }
107 | let assets = await this.loadAssets();
108 | for (let i = 0; i < assets.length; i++) {
109 | assets[i].chain = chains[assets[i].chain_id];
110 | assets[i].value = new Decimal(
111 | new Decimal(assets[i].balance).times(assets[i].price_usd).toFixed(8)
112 | ).toFixed();
113 | assets[i].change_usd = new Decimal(assets[i].change_usd).toFixed(2);
114 | assets[i].price_usd = new Decimal(assets[i].price_usd).toFixed();
115 | if (new Decimal(assets[i].price_usd).cmp(1) > 0) {
116 | assets[i].price_usd = new Decimal(
117 | new Decimal(assets[i].price_usd).toFixed(2)
118 | ).toFixed();
119 | }
120 | }
121 | assets = assets
122 | .filter((asset) => {
123 | // All erc20 tokens are multisig asset
124 | return (
125 | multisigAssetSet[asset.asset_id] ||
126 | asset.chain_id === "43d61dcd-e413-450d-80b8-101d5e903357"
127 | );
128 | })
129 | .sort((a, b) => {
130 | let value = new Decimal(a.value).cmp(b.value);
131 | if (value !== 0) {
132 | return -value;
133 | }
134 | return -new Decimal(a.price_usd).cmp(b.price_usd);
135 | });
136 | this.setState({
137 | assets: assets,
138 | selectedAssets: storage.getSelectedAssets(),
139 | });
140 | }
141 |
142 | componentDidMount() {
143 | this.loadFullData();
144 | }
145 |
146 | render() {
147 | const i18n = window.i18n;
148 | let state = this.state;
149 |
150 | let assets = this.filteredAssets().map((asset) => {
151 | return (
152 | this.toggleSelect(asset.asset_id)}
156 | >
157 |
158 | {state.selectedAssets[asset.asset_id] === 0 ? (
159 |
160 | ) : (
161 |
162 | )}
163 |
164 |
165 |
166 | {asset.balance} {asset.symbol}
167 |
≈ ${asset.value}
168 |
169 |
170 |
-1 ? 'red' : 'green'
173 | }
174 | >
175 | {asset.change_usd}%
176 |
177 |
${asset.price_usd}
178 |
179 |
180 | );
181 | });
182 |
183 | return (
184 |
185 |
186 |
190 |
191 |
192 |
198 |
199 |
200 |
201 |
202 |
203 |
204 | );
205 | }
206 | }
207 |
208 | export default Modal;
209 |
--------------------------------------------------------------------------------
/src/home/modal.module.scss:
--------------------------------------------------------------------------------
1 | .modal {
2 | background: rgba(0, 0, 0, 0.6);
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | z-index: 999;
7 | width: 100%;
8 | height: 100%;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: flex-end;
12 | transition: all 1s ease-out;
13 |
14 | .container {
15 | background: white;
16 | border-radius: 1.3rem 1.3rem 0 0;
17 | height: 90%;
18 | padding: 3rem 1.6rem;
19 | display: flex;
20 | flex-direction: column;
21 | }
22 | main {
23 | flex-grow: 1;
24 | overflow: auto;
25 | }
26 | header {
27 | display: flex;
28 | margin-bottom: 2rem;
29 | .title {
30 | flex-grow: 1;
31 | }
32 | }
33 | .search {
34 | background: #f5f7fa;
35 | border-radius: 2rem;
36 | margin-bottom: 3rem;
37 | padding: 1.2rem 1.6rem;
38 | display: flex;
39 | align-items: center;
40 | input {
41 | background: #f5f7fa;
42 | border: none;
43 | font-size: 1.6rem;
44 | padding: 0 1.2rem;
45 | flex-grow: 1;
46 | }
47 | }
48 | .item {
49 | display: flex;
50 | align-items: center;
51 | justify-content: center;
52 | margin-bottom: 3.2rem;
53 | .state {
54 | padding-right: 1.2rem;
55 | }
56 | .price {
57 | color: #b8bdc7;
58 | font-size: 1.4rem;
59 | margin-top: 0.4rem;
60 | }
61 | .red {
62 | color: #e57874;
63 | }
64 | .green {
65 | color: #5dbc7a;
66 | }
67 | .info {
68 | flex-grow: 1;
69 | padding-left: 1.6rem;
70 | }
71 | .value {
72 | text-align: right;
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/home/notfound.js:
--------------------------------------------------------------------------------
1 | import styles from "./notfound.module.scss";
2 | import { ReactComponent as NotFoundIcon } from "../statics/images/notfound.svg";
3 |
4 | function NotFound() {
5 | const i18n = window.i18n;
6 |
7 | return (
8 |
9 |
10 |
{ i18n.t("home.notfound.h1") }
11 | { i18n.t("home.notfound.h2") }
12 |
13 | )
14 | }
15 |
16 | export default NotFound;
17 |
--------------------------------------------------------------------------------
/src/home/notfound.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | color: #B8BDC7;
3 | min-height: 100vh;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | justify-content: center;
8 | padding: 0 5rem 16rem;
9 | }
10 | .h3 {
11 | font-size: 1.8rem;
12 | margin-top: 3.2rem;
13 | }
14 | .h4 {
15 | font-size: 1.4rem;
16 | font-weight: normal;
17 | margin-top: 1.6rem;
18 | text-align: center;
19 | }
20 |
--------------------------------------------------------------------------------
/src/home/view.js:
--------------------------------------------------------------------------------
1 | import Index from "./index.js";
2 | import NotFound from "./notfound.js";
3 |
4 | const view = {
5 | Index: Index,
6 | NotFound: NotFound,
7 | };
8 |
9 | export default view;
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import "./index.scss";
2 |
3 | import React from "react";
4 | import ReactDOM from "react-dom";
5 |
6 | import Locale from "./locales";
7 | import App from "./App";
8 | import reportWebVitals from "./reportWebVitals";
9 |
10 | window.i18n = new Locale(navigator.language);
11 |
12 | ReactDOM.render(
13 |
14 |
15 | ,
16 | document.getElementById("root")
17 | );
18 |
19 | // If you want to start measuring performance in your app, pass a function
20 | // to log results (for example: reportWebVitals(console.log))
21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
22 | reportWebVitals();
23 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | outline: none;
6 | }
7 |
8 | html {
9 | background: #f1f2f8;
10 | font-size: 62.5%;
11 | }
12 |
13 | body {
14 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
15 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
16 | sans-serif;
17 | -webkit-font-smoothing: antialiased;
18 | -moz-osx-font-smoothing: grayscale;
19 | color: #333;
20 | font-size: 1.6rem;
21 | line-height: 125%;
22 | }
23 |
24 | code {
25 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
26 | monospace;
27 | }
28 |
29 | a {
30 | color: #333;
31 | cursor: pointer;
32 | text-decoration: none;
33 | }
34 |
35 | ul,
36 | ol {
37 | list-style-type: none;
38 | }
39 |
40 | img {
41 | max-width: 100%;
42 | }
43 |
44 | input,
45 | textarea,
46 | button {
47 | font-size: 1.6rem;
48 | }
49 |
50 | .red {
51 | color: #e57874;
52 | }
53 |
54 | .green {
55 | color: #5dbc7a;
56 | }
57 |
58 | button.submit {
59 | background: #3d404b;
60 | border: 0;
61 | border-radius: 4.8rem;
62 | color: white;
63 | cursor: pointer;
64 | padding: 1.4rem 3.8rem;
65 |
66 | &.false, &.prepare {
67 | background: rgba(61, 64, 75, 0.16);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/locales/en.js:
--------------------------------------------------------------------------------
1 | const global = {
2 | loading: "Loading...",
3 | };
4 |
5 | const home = {
6 | home: {
7 | header: {
8 | title: "Multisig Wallet (%{text})",
9 | },
10 | assets: "Assets",
11 | modal: {
12 | title: "Assets Management",
13 | search_placeholder: "Name, Symbol",
14 | },
15 | notfound: {
16 | h1: "Page not found",
17 | h2: "The page you are looking for doesn’t exist or has been moved.",
18 | }
19 | },
20 | };
21 |
22 | const asset = {
23 | asset: {
24 | header: {
25 | title: "Asset Info"
26 | },
27 | action: {
28 | send: "Send",
29 | receive: "Receive",
30 | },
31 | blank: "NO TRANSACTION",
32 | memo: "TRANSACTION",
33 | transactions: "Transactions",
34 | signed: "Transaction is processing",
35 | modal: {
36 | title: "Send To",
37 | wallet: "Transfer from wallet",
38 | recipient: "Pay by others",
39 | },
40 | contacts: {
41 | title: "Send to friends",
42 | search_placeholder: "Mixin ID, Name",
43 | },
44 | },
45 | };
46 |
47 | const transfer = {
48 | transfer: {
49 | header: {
50 | recipient: "Create Recipient Card",
51 | transfer: "Transfer from Wallet",
52 | withdrawal: "Withdrawal to %{name}",
53 | show: "Transaction",
54 | },
55 | default: {
56 | memo: {
57 | transfer: "From Wallet",
58 | recipient: "From Recipient",
59 | }
60 | },
61 | balance: "BALANCE",
62 | amount: "Amount",
63 | memo: "Memo",
64 | forward: "Send to others",
65 | pay: "Generate & pay",
66 | withdrawal: "Withdrawal",
67 | card: {
68 | title: "Multisig Wallet",
69 | description: "From %{body}",
70 | withdrawal: "Withdrawal %{amount} %{symbol} to %{user}",
71 | icon_url:
72 | "https://mixin-images.zeromesh.net/TZ04DRR2tAb7UTHYSzGW_ygMjXpHJnfQvSASFA7jC_biVLCqJBsucuNDg09jKL3nuMQPt6ZmUOabsN-ORnWit4Ml7QEpR9E0HTl1qQ=s256",
73 | },
74 | detail: {
75 | transaction_id: "Transaction ID",
76 | asset_type: "Asset Type",
77 | state: "UTXO state",
78 | amount: "Transfer Amount",
79 | threshold: "Threshold",
80 | members: "Members",
81 | receivers: "Receivers",
82 | signers: "Signers",
83 | memo: "Memo",
84 | time: "Updated At",
85 | send: "Submit the Transaction",
86 | sign: "Sign Multisig Transaction",
87 | },
88 | },
89 | };
90 |
91 | const guide = {
92 | guide: {
93 | title: "Multisig Wallet",
94 | text : `
95 | Create a group with and only with the signers.
96 | Append ^T to the end of the group name.T is the threshold of signers.
97 | Invite me to the group and open me from there.
98 | `,
99 | }
100 | };
101 |
102 | const locale = {
103 | ...global,
104 | ...home,
105 | ...guide,
106 | ...asset,
107 | ...transfer,
108 | };
109 |
110 | export default locale;
111 |
--------------------------------------------------------------------------------
/src/locales/index.js:
--------------------------------------------------------------------------------
1 | import Polyglot from "node-polyglot";
2 | import en from "./en.js";
3 |
4 | class Locale {
5 | constructor(lang) {
6 | let locale = !!lang && lang.indexOf("zh") >= 0 ? "zh" : "en";
7 | this.polyglot = new Polyglot({ locale: locale });
8 | this.polyglot.extend(en);
9 | }
10 |
11 | t(key, options) {
12 | return this.polyglot.t(key, options);
13 | }
14 |
15 | tt() {
16 | var that = this;
17 | function t(key, options) {
18 | return that.polyglot.t(key, options);
19 | }
20 | return t;
21 | }
22 | }
23 |
24 | export default Locale;
25 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = (onPerfEntry) => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom";
6 |
--------------------------------------------------------------------------------
/src/statics/images/bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MixinNetwork/multisig-bot/d6e26a8442f4f1bb1911aaf3474c17d9c69b2b4a/src/statics/images/bg.png
--------------------------------------------------------------------------------
/src/statics/images/bg_texture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MixinNetwork/multisig-bot/d6e26a8442f4f1bb1911aaf3474c17d9c69b2b4a/src/statics/images/bg_texture.png
--------------------------------------------------------------------------------
/src/statics/images/ic_amount.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/statics/images/ic_close.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/statics/images/ic_guide.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/src/statics/images/ic_left.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/statics/images/ic_link.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/src/statics/images/ic_right.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/statics/images/ic_search.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/statics/images/ic_select.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/statics/images/ic_selected.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/statics/images/ic_setting.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/statics/images/ic_transaction.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/statics/images/ic_wallet.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/statics/images/loading_spin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/statics/images/notfound.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/transfer/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 | import React, { Component } from "react";
3 | import { Redirect } from "react-router-dom";
4 | import Decimal from "decimal.js";
5 | import mixin from "bot-api-js-client";
6 | import { v4 as uuidv4 } from "uuid";
7 | import {Base64} from 'js-base64';
8 |
9 | import {
10 | ApiGetAsset,
11 | ApiGetChains,
12 | ApiGetConversation,
13 | ApiPostPayments,
14 | ApiGetCode,
15 | } from "../api";
16 | import util from "../api/util.js";
17 | import Header from "../components/header.js";
18 | import AssetIcon from "../components/cover.js";
19 | import Loading from "../components/loading.js";
20 | import background from "../statics/images/bg.png";
21 | import { ReactComponent as AmountIcon } from "../statics/images/ic_amount.svg";
22 |
23 | /*
24 | * Transfer in from wallet
25 | * Pay by others
26 | */
27 | class Index extends Component {
28 | constructor(props) {
29 | super(props);
30 |
31 | const type = props.location.pathname.includes("transfer")
32 | ? "transfer"
33 | : "recipient";
34 |
35 | this.state = {
36 | assetId: props.match.params.id,
37 | conversation: {},
38 | asset: {},
39 | amount: "",
40 | value: 0,
41 | memo: "",
42 | type: type,
43 | loading: true,
44 | home: false,
45 | };
46 | }
47 |
48 | handleChange = (e) => {
49 | const { name, value } = e.target;
50 | let state = { [name]: value };
51 | if (name === "amount") {
52 | let val = value || "0";
53 | let v = new Decimal(
54 | new Decimal(val).times(this.state.asset.price_usd).toFixed(8)
55 | ).toFixed();
56 | state["value"] = v;
57 | }
58 | this.setState(state);
59 | }
60 |
61 | handleSubmit = () => {
62 | let participantIds = [];
63 | this.state.conversation.participants.forEach((p) => {
64 | // skip current and old multisig bot
65 | if (
66 | process.env.REACT_APP_CLIENT_ID !== p.user_id &&
67 | "37e040ec-df91-47a7-982e-0e118932fa8b" !== p.user_id
68 | ) {
69 | participantIds.push(p.user_id);
70 | }
71 | });
72 | let params = {
73 | asset_id: this.state.asset.asset_id,
74 | amount: this.state.amount,
75 | trace_id: uuidv4(),
76 | memo: this.state.memo || "",
77 | opponent_multisig: {
78 | receivers: participantIds,
79 | threshold: util.parseThreshold(this.state.conversation.name),
80 | },
81 | };
82 | if (params.memo.trim().length === 0) {
83 | params.memo = this.state.type === "transfer" ? window.i18n.t("transfer.default.memo.transfer") : window.i18n.t("transfer.default.memo.recipient");
84 | }
85 | ApiPostPayments(params).then((resp) => {
86 | if (resp.error) {
87 | return;
88 | }
89 |
90 | let text = `https://mixin.one/codes/${resp.data.code_id}`;
91 | console.log(text);
92 | if (this.state.type === "transfer") {
93 | window.open(text);
94 | this.loadCode(resp.data.code_id);
95 | return;
96 | }
97 |
98 | let description = window.i18n.t("transfer.card.description", {
99 | body: this.state.conversation.name,
100 | });
101 |
102 | let data = `{
103 | "action": "${text}",
104 | "app_id": "${process.env.REACT_APP_CLIENT_ID}",
105 | "icon_url": "https://mixin-images.zeromesh.net/TZ04DRR2tAb7UTHYSzGW_ygMjXpHJnfQvSASFA7jC_biVLCqJBsucuNDg09jKL3nuMQPt6ZmUOabsN-ORnWit4Ml7QEpR9E0HTl1qQ=s256",
106 | "description": "${description.slice(0, 128)}",
107 | "title": "${window.i18n.t("transfer.card.title")}"
108 | }`;
109 | window.open(
110 | "mixin://send?category=app_card&data=" + encodeURIComponent(Base64.encode(data))
111 | );
112 | this.setState({ home: true });
113 | });
114 | }
115 |
116 | sleep(ms) {
117 | return new Promise(resolve => setTimeout(resolve, ms));
118 | }
119 |
120 | async loadCode(codeId) {
121 | let code = await ApiGetCode(codeId);
122 | if (code.data && code.data.status === "paid") {
123 | this.setState({ home: true });
124 | }
125 | await this.sleep(1000);
126 | return this.loadCode(codeId);
127 | }
128 |
129 | async loadAsset() {
130 | let asset = await ApiGetAsset(this.state.assetId);
131 | if (asset.data) {
132 | return asset.data;
133 | }
134 | return this.loadAsset();
135 | }
136 |
137 | async loadChains() {
138 | let chains = await ApiGetChains();
139 | if (!chains.error) {
140 | return chains;
141 | }
142 | return this.loadChains();
143 | }
144 |
145 | async loadConversation() {
146 | let conversation = await ApiGetConversation(mixin.util.conversationId());
147 | if (conversation.data) {
148 | return conversation.data;
149 | }
150 | if (conversation.error.code === 404) {
151 | return; // TODO
152 | }
153 | return this.loadConversation();
154 | }
155 |
156 | async loadFullData() {
157 | var that = this;
158 | let conversation = await that.loadConversation();
159 | let chains = await that.loadChains();
160 | let asset = await that.loadAsset();
161 | asset.chain = chains[asset.chain_id];
162 | this.setState({ conversation: conversation, asset: asset, loading: false });
163 | }
164 |
165 | componentDidMount() {
166 | this.loadFullData();
167 | }
168 |
169 | render() {
170 | const i18n = window.i18n;
171 | let state = this.state;
172 |
173 | if (state.loading) {
174 | return ;
175 | }
176 |
177 | if (state.home) {
178 | return ;
179 | }
180 |
181 | let ready = state.amount !== "" && (new Decimal(state.amount)).gt(new Decimal("0"));
182 | let transfer = (
183 |
189 | );
190 |
191 | let recipient = (
192 |
198 | );
199 |
200 | return (
201 |
205 |
206 |
207 |
210 |
211 | {state.asset.symbol}
212 |
213 | {state.asset.balance}
214 |
215 | {i18n.t("transfer.balance")}
216 |
217 |
218 |
219 |
220 |
228 |
{state.value} USD
229 |
230 |
231 |
232 |
233 |
239 |
240 |
241 | {state.type === "transfer" && transfer}
242 | {state.type === "recipient" && recipient}
243 |
244 |
245 |
246 | );
247 | }
248 | }
249 |
250 | export default Index;
251 |
--------------------------------------------------------------------------------
/src/transfer/index.module.scss:
--------------------------------------------------------------------------------
1 | .transfer, .withdrawal {
2 | background-position: top;
3 | background-size: 100% auto;
4 | background-repeat: no-repeat;
5 | min-height: 100vh;
6 | padding: 0 1rem;
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: flex-end;
10 |
11 | main {
12 | background: white;
13 | border-radius: 1.6rem 1.6rem 0 0;
14 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
15 | flex-grow: 1;
16 | padding: 4rem 1rem;
17 | display: flex;
18 | flex-direction: column;
19 | align-items: center;
20 | }
21 |
22 | .icon {
23 | margin-bottom: 3.2rem;
24 | }
25 |
26 | .group {
27 | background: #f5f7fa;
28 | border-radius: 0.8rem;
29 | width: 100%;
30 | margin-bottom: 1rem;
31 | padding: 0.8rem 1.6rem;
32 |
33 | input {
34 | border: 0;
35 | background: #f5f7fa;
36 | width: 100%;
37 | }
38 | }
39 |
40 | .amount {
41 | color: #b8bdc7;
42 | display: flex;
43 | align-items: center;
44 | }
45 |
46 | .body {
47 | flex-grow: 1;
48 | }
49 |
50 | .value {
51 | color: #b8bdc7;
52 | font-size: 1.2rem;
53 | }
54 |
55 | .memo {
56 | padding: 2rem 1.6rem;
57 | }
58 |
59 | .action {
60 | margin-top: 1.6rem;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/transfer/show.js:
--------------------------------------------------------------------------------
1 | import styles from "./show.module.scss";
2 | import Decimal from "decimal.js";
3 |
4 | import {
5 | ApiGetAsset,
6 | ApiGetChains,
7 | ApiPostUsersFetch,
8 | ApiPostMultisigsRequests,
9 | ApiPostExternalProxy,
10 | ApiGetCode,
11 | ApiGetMe,
12 | } from "../api";
13 | import React, { Component } from "react";
14 | import { Redirect } from "react-router-dom";
15 |
16 | import Loading from "../components/loading.js";
17 | import background from "../statics/images/bg.png";
18 | import Header from "../components/header.js";
19 | import AssetIcon from "../components/cover.js";
20 |
21 | class Show extends Component {
22 | constructor(props) {
23 | super(props);
24 |
25 | this.state = {
26 | utxo: props.location.utxo,
27 | asset: {},
28 | users: {},
29 | user: {},
30 | loading: true,
31 | };
32 | }
33 |
34 | async loadUser() {
35 | let user = await ApiGetMe();
36 | if (user.data) {
37 | return user.data;
38 | }
39 | return this.loadUser();
40 | }
41 |
42 | async loadAsset() {
43 | let id = this.props.location.utxo.asset_id;
44 | let asset = await ApiGetAsset(id);
45 | if (asset.data) {
46 | return asset.data;
47 | }
48 | return this.loadAsset();
49 | }
50 |
51 | async loadChains() {
52 | let chains = await ApiGetChains();
53 | if (!chains.error) {
54 | return chains;
55 | }
56 | return this.loadChains();
57 | }
58 |
59 | async loadUsers(members) {
60 | let users = await ApiPostUsersFetch(members);
61 | if (!users.error) {
62 | return users.data;
63 | }
64 | return this.loadUsers(members);
65 | }
66 |
67 | async postMultisigsRequest() {
68 | let request = await ApiPostMultisigsRequests("sign", this.state.utxo.signed_tx);
69 | if (!request.error) {
70 | return request.data;
71 | }
72 | return this.postMultisigsRequest();
73 | }
74 |
75 | async loadFullData() {
76 | let utxo = this.state.utxo;
77 | let chains = await this.loadChains();
78 | let asset = await this.loadAsset();
79 | asset.chain = chains[asset.chain_id];
80 | asset.value = new Decimal(
81 | new Decimal(this.state.utxo.amount).times(asset.price_usd).toFixed(8)
82 | ).toFixed();
83 | let memberIds = [...this.state.utxo.members];
84 | memberIds.push(this.state.utxo.user_id);
85 | memberIds.push(this.state.utxo.sender);
86 | if (this.state.utxo.state === "signed") {
87 | let request = await this.postMultisigsRequest();
88 | utxo.transferAmount = request.amount;
89 | utxo.signers = request.signers;
90 | utxo.receivers = request.receivers;
91 | memberIds.push(...request.signers);
92 | memberIds.push(...request.receivers);
93 | }
94 | let users = await this.loadUsers(memberIds);
95 | let usersMap = {};
96 | for (let i in users) {
97 | usersMap[users[i].user_id] = users[i];
98 | }
99 | let user = await this.loadUser()
100 | this.setState({
101 | utxo: utxo,
102 | asset: asset,
103 | users: usersMap,
104 | user: user,
105 | loading: false,
106 | });
107 | }
108 |
109 | sleep(ms) {
110 | return new Promise(resolve => setTimeout(resolve, ms));
111 | }
112 |
113 | async loadCode(codeId) {
114 | let code = await ApiGetCode(codeId);
115 | if (code.data && code.data.state === "signed") {
116 | let utxo = this.state.utxo;
117 | utxo.threshold = code.data.threshold;
118 | utxo.signers = code.data.signers;
119 | utxo.signed_tx = code.data.raw_transaction;
120 | this.setState({
121 | utxo: utxo,
122 | });
123 | if (utxo.signers.length >= utxo.threshold) {
124 | this.sendRawTransaction();
125 | }
126 | return;
127 | }
128 | await this.sleep(1000);
129 | return this.loadCode(codeId);
130 | }
131 |
132 | sendRawTransaction = () => {
133 | ApiPostExternalProxy(this.state.utxo.signed_tx).then((resp) => {
134 | if (resp.error) {
135 | return;
136 | }
137 | let utxo = this.state.utxo;
138 | utxo.state = "spent";
139 | this.setState({ utxo: utxo });
140 | });
141 | }
142 |
143 | signRawTransaction = () => {
144 | ApiPostMultisigsRequests("sign", this.state.utxo.signed_tx).then((resp) => {
145 | if (resp.error) {
146 | return;
147 | }
148 | let text = `https://mixin.one/codes/${resp.data.code_id}`;
149 | console.log(text);
150 | window.open('mixin://codes/' + resp.data.code_id);
151 | this.loadCode(resp.data.code_id);
152 | })
153 | }
154 |
155 | componentDidMount() {
156 | if (!!this.state.utxo) {
157 | this.loadFullData();
158 | }
159 | }
160 |
161 | render() {
162 | const i18n = window.i18n;
163 | let state = this.state;
164 |
165 | if (!state.utxo) {
166 | return (
167 |
168 | );
169 | }
170 |
171 | if (state.loading) {
172 | return
173 | }
174 |
175 | let members = state.utxo.members.map((m) => {
176 | let user = state.users[m];
177 | if (!user) {
178 | return "";
179 | }
180 | return (
181 |
182 |
183 |
184 | );
185 | });
186 |
187 | let signers;
188 | if (state.utxo.signers && state.utxo.signers.length > 0) {
189 | signers = state.utxo.signers.map((m) => {
190 | let user = state.users[m];
191 | if (!user) {
192 | return "";
193 | }
194 | return (
195 |
196 |
197 |
198 | );
199 | });
200 | }
201 |
202 | let receivers;
203 | if (state.utxo.receivers && state.utxo.receivers.length > 0) {
204 | receivers = state.utxo.receivers.map((m) => {
205 | let user = state.users[m];
206 | if (!user) {
207 | return "";
208 | }
209 | return (
210 |
211 |
212 |
213 | );
214 | });
215 | }
216 |
217 | let sendRawTransactionButton = (
218 |
221 | );
222 |
223 | let signTransactionButton = (
224 |
227 | );
228 |
229 | return (
230 |
233 |
234 |
235 |
236 |
237 |
238 | {state.utxo.state === "spent" ? "-" : "+"}
239 | {state.utxo.amount}
240 | {state.asset.symbol}
241 |
242 |
≈ ${state.asset.value}
243 |
244 |
245 |
246 |
247 | { i18n.t("transfer.detail.transaction_id") }
248 |
249 | { state.utxo.utxo_id }
250 |
251 |
252 |
253 | { i18n.t("transfer.detail.asset_type") }
254 |
255 | { state.asset.name }
256 |
257 |
258 |
259 | { i18n.t("transfer.detail.state") }
260 |
261 | { state.utxo.state }
262 |
263 | {
264 | this.state.utxo.state === "signed" && (
265 |
266 |
267 | { i18n.t("transfer.detail.amount") }
268 |
269 | -{ state.utxo.transferAmount }
270 |
271 | )
272 | }
273 |
274 |
275 | { i18n.t("transfer.detail.threshold") }
276 |
277 | { state.utxo.threshold }
278 |
279 |
280 |
281 | { i18n.t("transfer.detail.members") }
282 |
283 | { members }
284 |
285 | {
286 | state.utxo.state === "signed" && (
287 |
288 |
289 | { i18n.t("transfer.detail.receivers") }
290 |
291 | { receivers }
292 |
293 | )
294 | }
295 | {
296 | state.utxo.state === "signed" && (
297 |
298 |
299 | { i18n.t("transfer.detail.signers") }
300 |
301 | { signers }
302 |
303 | )
304 | }
305 |
306 |
307 | { i18n.t("transfer.detail.memo") }
308 |
309 | { state.utxo.memo }
310 |
311 |
312 |
313 | { i18n.t("transfer.detail.time") }
314 |
315 | { state.utxo.updated_at }
316 |
317 |
318 | { state.utxo.state === "signed" && state.utxo.signers.length >= state.utxo.threshold && sendRawTransactionButton }
319 | { state.utxo.state === "signed" && state.utxo.signers.length < state.utxo.threshold && !state.utxo.signers.includes(state.user.user_id) && signTransactionButton }
320 |
321 |
322 |
323 | );
324 | }
325 | }
326 |
327 | export default Show;
328 |
--------------------------------------------------------------------------------
/src/transfer/show.module.scss:
--------------------------------------------------------------------------------
1 | .show {
2 | background-position: top;
3 | background-size: 100% auto;
4 | background-repeat: no-repeat;
5 | min-height: 100vh;
6 | padding: 0 1rem;
7 | display: flex;
8 | flex-direction: column;
9 | }
10 |
11 | .info {
12 | background: white;
13 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
14 | border-radius: 1.6rem;
15 | padding: 4rem 4.2rem 1.6rem;
16 | margin: 0 0 1rem;
17 | display: flex;
18 | flex-direction: column;
19 | align-items: center;
20 | .balance {
21 | font-size: 3.2rem;
22 | font-weight: 600;
23 | margin: 0.6rem 0;
24 | span {
25 | font-size: 1.6rem;
26 | font-weight: normal;
27 | }
28 | }
29 | .value {
30 | font-size: 1.4rem;
31 | color: #b8bdc7;
32 | margin-bottom: 1.6rem;
33 | }
34 | }
35 |
36 | .detail {
37 | background: white;
38 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
39 | border-radius: 1.6rem 1.6rem 0 0;
40 | flex-grow: 1;
41 | padding: 2rem 1.6rem;
42 | word-break: break-all;
43 | .group {
44 | margin-bottom: 2rem;
45 | }
46 | .title {
47 | color: #B8BDC7;
48 | font-size: 1.4rem;
49 | }
50 | .user {
51 | border-radius: 1.6rem;
52 | width: 3.2rem;
53 | margin: .3rem 1rem 0 0;
54 | }
55 | .action {
56 | text-align: center;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/transfer/view.js:
--------------------------------------------------------------------------------
1 | import Index from "./index.js";
2 | import Withdrawal from "./withdrawal.js";
3 | import Show from "./show.js";
4 |
5 | const view = {
6 | Index: Index,
7 | Withdrawal: Withdrawal,
8 | Show: Show,
9 | };
10 |
11 | export default view;
12 |
--------------------------------------------------------------------------------
/src/transfer/withdrawal.js:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.scss";
2 | import React, { Component } from "react";
3 | import { Redirect } from "react-router-dom";
4 | import Decimal from "decimal.js";
5 | import mixin from "bot-api-js-client";
6 | import {Base64} from 'js-base64';
7 |
8 | import {
9 | ApiGetAsset,
10 | ApiGetChains,
11 | ApiGetMultisigsOutputs,
12 | ApiGetConversation,
13 | ApiPostUsersFetch,
14 | ApiPostGhostKeys,
15 | ApiPostMultisigsRequests,
16 | ApiPostExternalProxy,
17 | ApiGetCode,
18 | } from "../api";
19 | import util from "../api/util.js";
20 | import Header from "../components/header.js";
21 | import AssetIcon from "../components/cover.js";
22 | import Loading from "../components/loading.js";
23 | import background from "../statics/images/bg.png";
24 | import { ReactComponent as AmountIcon } from "../statics/images/ic_amount.svg";
25 |
26 | class Withdrawal extends Component {
27 | constructor(props) {
28 | super(props);
29 |
30 | this.state = {
31 | assetId: props.match.params.id,
32 | userId: props.match.params.user_id,
33 | conversation: {},
34 | asset: {},
35 | outputs: [],
36 | user: {},
37 | amount: "",
38 | value: 0,
39 | memo: "",
40 | loading: true,
41 | submit: "prepare", // prepare, ready, submitting
42 | back: false,
43 | };
44 | }
45 |
46 | handleChange = (e) => {
47 | const { name, value } = e.target;
48 | let state = { [name]: value };
49 | if (name === "amount") {
50 | let val = value || "0";
51 | let v = new Decimal(
52 | new Decimal(val).times(this.state.asset.price_usd).toFixed(8)
53 | ).toFixed();
54 | state["value"] = v;
55 |
56 | let ready = value !== "" && (new Decimal(value)).gt(new Decimal("0")) && (new Decimal(value)).lte(new Decimal(this.state.asset.balance));
57 | if (this.state.submit !== "submitting") {
58 | state["submit"] = ready ? "ready" : "prepare";
59 | }
60 | }
61 | this.setState(state);
62 | }
63 |
64 | fullName = (u) => {
65 | if (u.full_name) {
66 | return u.full_name.trim().slice(0, 18);
67 | }
68 | return ""
69 | }
70 |
71 | toHex(s) {
72 | if (typeof(s) !== 'string') {
73 | return '';
74 | }
75 | s = unescape(encodeURIComponent(s))
76 | let h = ''
77 | for (var i = 0; i < s.length; i++) {
78 | h += s.charCodeAt(i).toString(16)
79 | }
80 | return h
81 | }
82 |
83 | buildThresholdScript (t) {
84 | var s = t.toString(16);
85 | if (s.length === 1) {
86 | s = '0' + s;
87 | }
88 | if (s.length > 2) {
89 | alert('INVALID THRESHOLD ' + t);
90 | }
91 | return 'fffe' + s;
92 | }
93 |
94 | handleSubmit = () => {
95 | if (this.state.submit !== "ready") {
96 | return;
97 | }
98 | this.submit();
99 | }
100 |
101 | async submit() {
102 | let inputAmount = new Decimal(0);
103 | let amount = new Decimal(this.state.amount);
104 | let tx = {
105 | version: 2,
106 | asset: this.state.asset.mixin_id,
107 | inputs: [],
108 | outputs: [],
109 | extra: this.toHex(this.state.memo),
110 | }
111 | for (let i in this.state.outputs) {
112 | let utxo = this.state.outputs[i];
113 | if (utxo.asset_id !== this.state.assetId) {
114 | continue;
115 | };
116 | inputAmount = inputAmount.add(new Decimal(utxo.amount));
117 | tx.inputs.push({
118 | hash: utxo.transaction_hash,
119 | index: utxo.output_index
120 | });
121 | if (inputAmount.cmp(amount) >= 0) {
122 | break;
123 | }
124 | }
125 | if (inputAmount.cmp(amount) < 0) {
126 | alert('TOO MUCH');
127 | return;
128 | }
129 | let receivers = await this.loadGhostKeys([this.state.userId], 0);
130 | let output = {
131 | mask: receivers.mask,
132 | keys: receivers.keys,
133 | };
134 | output.amount = amount.toString();
135 | output.script = this.buildThresholdScript(1);
136 | tx.outputs.push(output);
137 | if (inputAmount.cmp(amount) > 0) {
138 | let utxo = this.state.outputs[0];
139 | let members = await this.loadGhostKeys(utxo.members, 1);
140 | output = {
141 | mask: members.mask,
142 | keys: members.keys,
143 | };
144 | output.amount = inputAmount.sub(amount).toString();
145 | output.script = this.buildThresholdScript(utxo.threshold);
146 | tx.outputs.push(output)
147 | }
148 | console.log(JSON.stringify(tx));
149 | let raw = window.mixinGo.buildTransaction(JSON.stringify(tx));
150 | console.log(raw);
151 | ApiPostMultisigsRequests("sign", raw).then((resp) => {
152 | if (resp.error) {
153 | return;
154 | }
155 | let text = `https://mixin.one/codes/${resp.data.code_id}`;
156 | console.log(text);
157 | window.open('mixin://codes/' + resp.data.code_id);
158 | this.loadCode(resp.data.code_id);
159 | });
160 | }
161 |
162 | sleep(ms) {
163 | return new Promise(resolve => setTimeout(resolve, ms));
164 | }
165 |
166 | async loadCode(codeId) {
167 | let state = this.state;
168 | let code = await ApiGetCode(codeId);
169 | if (code.data && code.data.state === "signed") {
170 | if (code.data.signers.length < code.data.threshold) {
171 | let description = window.i18n.t("transfer.card.withdrawal", {
172 | amount: state.amount,
173 | symbol: state.asset.symbol,
174 | user: state.user.full_name,
175 | });
176 | let text = `https://multisig.mixin.zone/assets/${this.state.assetId}`;
177 | let data = `{
178 | "action": "${text}",
179 | "app_id": "${process.env.REACT_APP_CLIENT_ID}",
180 | "icon_url": "https://mixin-images.zeromesh.net/TZ04DRR2tAb7UTHYSzGW_ygMjXpHJnfQvSASFA7jC_biVLCqJBsucuNDg09jKL3nuMQPt6ZmUOabsN-ORnWit4Ml7QEpR9E0HTl1qQ=s256",
181 | "description": "${description.slice(0, 128)}",
182 | "title": "${window.i18n.t("transfer.card.title")}"
183 | }`;
184 | window.open(
185 | "mixin://send?category=app_card&data=" + encodeURIComponent(Base64.encode(data))
186 | );
187 | this.setState({ back: true });
188 | } else {
189 | let resp = await ApiPostExternalProxy(code.data.raw_transaction);
190 | if (!resp.error) {
191 | this.setState({ back: true });
192 | }
193 | }
194 | return;
195 | }
196 | await this.sleep(1000);
197 | return this.loadCode(codeId);
198 | }
199 |
200 | async loadGhostKeys(ids, index) {
201 | let keys = await ApiPostGhostKeys(ids, index);
202 | if (keys.data) {
203 | return keys.data;
204 | }
205 | return this.loadGhostKeys(ids, index);
206 | }
207 |
208 | async loadAsset() {
209 | let asset = await ApiGetAsset(this.state.assetId);
210 | if (asset.data) {
211 | return asset.data;
212 | }
213 | return this.loadAsset();
214 | }
215 |
216 | async loadChains() {
217 | let chains = await ApiGetChains();
218 | if (!chains.error) {
219 | return chains;
220 | }
221 | return this.loadChains();
222 | }
223 |
224 | async loadConversation() {
225 | let conversation = await ApiGetConversation(mixin.util.conversationId());
226 | if (conversation.data) {
227 | return conversation.data;
228 | }
229 | if (conversation.error.code === 404) {
230 | return; // TODO
231 | }
232 | return this.loadConversation();
233 | }
234 |
235 | async loadMultisigsOutputs(participants, threshold, offset, utxo) {
236 | let outputs = await ApiGetMultisigsOutputs(
237 | participants,
238 | threshold,
239 | "unspent",
240 | offset,
241 | );
242 | if (outputs.data) {
243 | utxo.push(...outputs.data);
244 | if (outputs.data.length < 500) {
245 | return utxo;
246 | }
247 | let output = outputs.data[outputs.data.length - 1];
248 | if (output) {
249 | offset = output.created_at;
250 | }
251 | }
252 | this.loadMultisigsOutputs(participants, threshold, offset, utxo);
253 | }
254 |
255 | async loadUsers() {
256 | let user = await ApiPostUsersFetch([this.state.userId]);
257 | if (user.data) {
258 | return user.data;
259 | }
260 | return this.loadUsers();
261 | }
262 |
263 | async loadFullData() {
264 | let that = this;
265 |
266 | let conversation = await that.loadConversation();
267 | let participants = [];
268 | conversation.participants.forEach((p) => {
269 | if (
270 | process.env.REACT_APP_CLIENT_ID !== p.user_id &&
271 | "37e040ec-df91-47a7-982e-0e118932fa8b" !== p.user_id
272 | ) {
273 | participants.push(p.user_id);
274 | }
275 | });
276 | let chains = await that.loadChains();
277 | let asset = await that.loadAsset();
278 | asset.chain = chains[asset.chain_id];
279 | asset.balance = "0";
280 | this.setState({
281 | conversation: conversation,
282 | asset: asset,
283 | loading: false,
284 | });
285 |
286 | let users = await that.loadUsers();
287 | if (users.length < 1) {
288 | return
289 | }
290 | let outputs = await that.loadMultisigsOutputs(
291 | participants,
292 | util.parseThreshold(conversation.name),
293 | "",
294 | []
295 | );
296 | let balance = outputs.reduce((a, c) => {
297 | if (c.asset_id === that.state.assetId && c.state === "unspent") {
298 | return a.plus(c.amount);
299 | }
300 | return a;
301 | }, new Decimal("0"));
302 | asset.balance = balance.toFixed();
303 | this.setState({
304 | conversation: conversation,
305 | asset: asset,
306 | outputs: outputs,
307 | user: users[0],
308 | loading: false,
309 | });
310 | }
311 |
312 | componentDidMount() {
313 | this.loadFullData();
314 | }
315 |
316 | render() {
317 | const i18n = window.i18n;
318 | let state = this.state;
319 |
320 | if (state.loading) {
321 | return ;
322 | }
323 |
324 | if (state.back) {
325 | return ;
326 | }
327 |
328 | return (
329 |
333 |
334 |
335 |
338 |
339 | {state.asset.symbol}
340 |
341 | {state.asset.balance}
342 |
343 | {i18n.t("transfer.balance")}
344 |
345 |
346 |
347 |
348 |
356 |
{state.value} USD
357 |
358 |
359 |
360 |
361 |
367 |
368 |
369 |
375 |
376 |
377 |
378 | );
379 | }
380 | }
381 |
382 | export default Withdrawal;
383 |
--------------------------------------------------------------------------------