├── .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 | {contact.full_name} 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 |
    79 |
    {i18n.t("asset.contacts.title")}
    80 | { 82 | props.handleSend(false); 83 | }} 84 | /> 85 |
    86 |
    87 | 88 | setText(e.target.value)} 93 | /> 94 |
    95 |
    96 |
      {contactList}
    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 |
      {transactionList}
    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 |
    16 |
    {i18n.t("asset.modal.title")}
    17 | { 19 | props.handleReceive(false); 20 | }} 21 | /> 22 |
    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 | {asset.name} 9 | {asset.chain.name} 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 &&
        {assets}
      } 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 |
      187 |
      {i18n.t("home.modal.title")}
      188 | this.props.handleModal(false)} /> 189 |
      190 |
      191 | 192 | 198 |
      199 |
      200 |
        {assets}
      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 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/statics/images/ic_close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/statics/images/ic_guide.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/statics/images/ic_left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/statics/images/ic_link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/statics/images/ic_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/statics/images/ic_search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/statics/images/ic_select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/statics/images/ic_selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/statics/images/ic_setting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/statics/images/ic_transaction.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/statics/images/ic_wallet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/statics/images/loading_spin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /src/statics/images/notfound.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 |
      208 | 209 |
      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 |
      336 | 337 |
      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 | --------------------------------------------------------------------------------