From 45d96b486e866f27ee5f7670d45e941adadfda3e Mon Sep 17 00:00:00 2001 From: Oleg Baranov Date: Wed, 24 Jan 2024 16:06:35 +0400 Subject: [PATCH] Tonutils storage provider integration --- app.go | 30 ++ core/api/client.go | 296 ++++++++++++++ core/client/storage.go | 21 + core/client/types.go | 37 ++ core/gostorage/client.go | 45 ++- core/gostorage/provider.go | 273 +++++++++++++ frontend/public/dark/logout.svg | 5 + frontend/public/light/logout.svg | 5 + frontend/src/App.tsx | 39 ++ frontend/src/assets/images/icons/ton.svg | 1 + frontend/src/base.scss | 139 ++++++- frontend/src/components/FilesTorrentMenu.tsx | 2 +- frontend/src/components/ModalAddProvider.tsx | 96 +++++ frontend/src/components/ModalDoTx.tsx | 90 +++++ .../src/components/ProvidersTorrentMenu.tsx | 365 +++++++++++++++--- frontend/src/components/Table.tsx | 5 +- frontend/src/tooltip.css | 2 +- frontend/wailsjs/go/main/App.d.ts | 10 + frontend/wailsjs/go/main/App.js | 20 + go.mod | 9 +- go.sum | 19 +- tonconnect/logo.png | Bin 67125 -> 39320 bytes 22 files changed, 1429 insertions(+), 80 deletions(-) create mode 100644 core/gostorage/provider.go create mode 100644 frontend/public/dark/logout.svg create mode 100644 frontend/public/light/logout.svg create mode 100644 frontend/src/assets/images/icons/ton.svg create mode 100644 frontend/src/components/ModalAddProvider.tsx create mode 100644 frontend/src/components/ModalDoTx.tsx diff --git a/app.go b/app.go index c73023e..7b1b020 100644 --- a/app.go +++ b/app.go @@ -255,6 +255,36 @@ func (a *App) GetSpeedLimit() *api.SpeedLimits { return limits } +func (a *App) GetProviderContract(hash, owner string) api.ProviderContract { + return a.api.GetProviderContract(hash, owner) +} + +func (a *App) FetchProviderRates(hash, provider string) api.ProviderRates { + return a.api.FetchProviderRates(hash, provider) +} + +func (a *App) RequestProviderStorageInfo(hash, provider, owner string) api.ProviderStorageInfo { + return a.api.RequestProviderStorageInfo(hash, provider, owner) +} + +func (a *App) BuildProviderContractData(hash, ownerAddr, amount string, providers []api.NewProviderData) *api.Transaction { + t, err := a.api.BuildProviderContractData(hash, ownerAddr, amount, providers) + if err != nil { + a.ShowWarnMsg(err.Error()) + return nil + } + return t +} + +func (a *App) BuildWithdrawalContractData(hash, ownerAddr string) *api.Transaction { + t, err := a.api.BuildWithdrawalContractData(hash, ownerAddr) + if err != nil { + a.ShowWarnMsg(err.Error()) + return nil + } + return t +} + func (a *App) openFile(data []byte) { if a.loaded { res := a.addByMeta(data) diff --git a/core/api/client.go b/core/api/client.go index 910fb3b..9c812db 100644 --- a/core/api/client.go +++ b/core/api/client.go @@ -2,9 +2,14 @@ package api import ( "context" + "encoding/base64" "encoding/hex" + "errors" "fmt" "github.com/tonutils/torrent-client/core/client" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-storage-provider/pkg/contract" "log" "math/big" "sort" @@ -31,6 +36,12 @@ type PlainFile struct { RawSize int64 } +type NewProviderData struct { + Key string + MaxSpan uint32 + PricePerMBDay string +} + type Torrent struct { ID string Name string @@ -84,6 +95,44 @@ type Speed struct { Download string } +type ProviderContract struct { + Success bool + Deployed bool + Address string + Providers []Provider + Balance string +} + +type ProviderRates struct { + Success bool + Reason string + Provider Provider +} + +type Provider struct { + Key string + LastProof string + PricePerDay string + Span string + Status string + Reason string + Progress float64 + Data NewProviderData +} + +type ProviderStorageInfo struct { + Status string + Reason string + Downloaded float64 +} + +type Transaction struct { + Body string + StateInit string + Address string + Amount string +} + type StorageClient interface { GetTorrents(ctx context.Context) (*client.TorrentsList, error) AddByHash(ctx context.Context, hash []byte, dir string) (*client.TorrentFull, error) @@ -98,6 +147,11 @@ type StorageClient interface { GetSpeedLimits(ctx context.Context) (*client.SpeedLimits, error) SetSpeedLimits(ctx context.Context, download, upload int64) error GetUploadStats(ctx context.Context, hash []byte) (uint64, error) + FetchProviderContract(ctx context.Context, torrentHash []byte, owner *address.Address) (*client.ProviderContractData, error) + FetchProviderRates(ctx context.Context, torrentHash, providerKey []byte) (*client.ProviderRates, error) + RequestProviderStorageInfo(ctx context.Context, torrentHash, providerKey []byte, owner *address.Address) (*client.ProviderStorageInfo, error) + BuildAddProviderTransaction(ctx context.Context, torrentHash []byte, owner *address.Address, providers []client.NewProviderData) (addr *address.Address, bodyData, stateInit []byte, err error) + BuildWithdrawalTransaction(torrentHash []byte, owner *address.Address) (addr *address.Address, bodyData []byte, err error) } type API struct { @@ -639,6 +693,248 @@ func (a *API) SetPriorities(hash string, list []string, priority int) error { return nil } +func (a *API) GetProviderContract(hash, ownerAddr string) ProviderContract { + hashBytes, err := toHashBytes(hash) + if err != nil { + return ProviderContract{Success: false} + } + + addr, err := address.ParseAddr(ownerAddr) + if err != nil { + log.Println("failed to get provider contract, parse addr error:", err.Error()) + return ProviderContract{Success: false} + } + + data, err := a.client.FetchProviderContract(a.globalCtx, hashBytes, addr) + if err != nil { + if errors.Is(err, contract.ErrNotDeployed) { + return ProviderContract{Success: true, Deployed: false} + } + log.Println("failed to get provider contract:", err.Error()) + return ProviderContract{Success: false} + } + + var providers []Provider + for _, p := range data.Providers { + since := "Never" + snc := time.Since(p.LastProofAt) + if snc < time.Minute { + since = fmt.Sprint(int(snc.Seconds())) + " seconds ago" + } else if snc < time.Hour { + since = fmt.Sprint(int(snc.Minutes())) + " minutes ago" + } else if snc < 24*time.Hour { + since = fmt.Sprint(int(snc.Hours())) + " hours ago" + } else if snc < 1000*24*time.Hour { + since = fmt.Sprint(int(snc.Hours())/24) + " days ago" + } + + every := "" + if p.MaxSpan < 3600 { + every = fmt.Sprint(p.MaxSpan/60) + " Minutes" + } else if p.MaxSpan < 100*3600 { + every = fmt.Sprint(p.MaxSpan/3600) + " Hours" + } else { + every = fmt.Sprint(p.MaxSpan/86400) + " Days" + } + + psi, err := a.client.RequestProviderStorageInfo(a.globalCtx, hashBytes, p.Key, addr) + if err != nil { + log.Println("failed to request provider info:", err.Error()) + return ProviderContract{Success: false} + } + + providers = append(providers, Provider{ + Key: strings.ToUpper(hex.EncodeToString(p.Key)), + LastProof: since, + Span: every, + Progress: psi.Progress, + Status: psi.Status, + Reason: psi.Reason, + PricePerDay: tlb.FromNanoTON(new(big.Int).Mul(p.RatePerMB.Nano(), big.NewInt(int64(data.Size/1024/1024)))).String() + " TON", + Data: NewProviderData{ + Key: hex.EncodeToString(p.Key), + MaxSpan: p.MaxSpan, + PricePerMBDay: p.RatePerMB.Nano().String(), + }, + }) + + } + + bal := data.Balance.String() + if idx := strings.IndexByte(bal, '.'); idx != -1 { + if len(bal) > idx+4 { + // max 4 digits after comma + bal = bal[:idx+4] + } + } + return ProviderContract{ + Success: true, + Deployed: true, + Address: data.Address.String(), + Providers: providers, + Balance: bal + " TON", + } +} + +func (a *API) FetchProviderRates(hash, provider string) ProviderRates { + hashBytes, err := toHashBytes(hash) + if err != nil { + return ProviderRates{Success: false, Reason: "failed to parse torrent hash: " + err.Error()} + } + + providerBytes, err := toHashBytes(provider) + if err != nil { + return ProviderRates{Success: false, Reason: "failed to parse provider hash: " + err.Error()} + } + + rates, err := a.client.FetchProviderRates(a.globalCtx, hashBytes, providerBytes) + if err != nil { + return ProviderRates{Success: false, Reason: err.Error()} + } + + span := uint32(86400) + if span > rates.MaxSpan { + span = rates.MaxSpan + } else if span < rates.MinSpan { + span = rates.MinSpan + } + + every := "" + if span < 3600 { + every = fmt.Sprint(span/60) + " minutes" + } else if span < 100*3600 { + every = fmt.Sprint(span/3600) + " hours" + } else { + every = fmt.Sprint(span/86400) + " days" + } + + ratePerMB := rates.RatePerMBDay.Nano() + min := rates.MinBounty.Nano() + perDay := new(big.Int).Mul(ratePerMB, big.NewInt(int64(rates.Size/1024/1024))) + if perDay.Cmp(min) < 0 { + // increase reward to fit min bounty + coff := new(big.Float).Quo(new(big.Float).SetInt(min), new(big.Float).SetInt(perDay)) + coff = coff.Add(coff, big.NewFloat(0.01)) // increase a bit to not be less than needed + ratePerMB, _ = new(big.Float).Mul(new(big.Float).SetInt(ratePerMB), coff).Int(ratePerMB) + perDay = new(big.Int).Mul(ratePerMB, big.NewInt(int64(rates.Size/1024/1024))) + } + + return ProviderRates{ + Success: true, + Provider: Provider{ + Key: strings.ToUpper(hex.EncodeToString(providerBytes)), + PricePerDay: tlb.FromNanoTON(perDay).String() + " TON", + Span: every, + Data: NewProviderData{ + Key: hex.EncodeToString(providerBytes), + MaxSpan: span, + PricePerMBDay: ratePerMB.String(), + }, + }, + } +} + +func (a *API) RequestProviderStorageInfo(hash, provider, ownerAddr string) ProviderStorageInfo { + hashBytes, err := toHashBytes(hash) + if err != nil { + return ProviderStorageInfo{Status: "internal"} + } + + providerBytes, err := toHashBytes(provider) + if err != nil { + return ProviderStorageInfo{Status: "internal"} + } + + addr, err := address.ParseAddr(ownerAddr) + if err != nil { + log.Println("failed to get provider contract, parse addr error:", err.Error()) + return ProviderStorageInfo{Status: "internal"} + } + + info, err := a.client.RequestProviderStorageInfo(a.globalCtx, hashBytes, providerBytes, addr) + if err != nil { + return ProviderStorageInfo{Status: "not_connected"} + } + + return ProviderStorageInfo{ + Status: info.Status, + Reason: info.Reason, + Downloaded: info.Progress, + } +} + +func (a *API) BuildProviderContractData(hash, ownerAddr, amount string, providers []NewProviderData) (*Transaction, error) { + hashBytes, err := toHashBytes(hash) + if err != nil { + return nil, err + } + + owner, err := address.ParseAddr(ownerAddr) + if err != nil { + return nil, err + } + + amt, err := tlb.FromTON(amount) + if err != nil { + return nil, err + } + + var prs []client.NewProviderData + for _, p := range providers { + keyBytes, err := toHashBytes(p.Key) + if err != nil { + return nil, fmt.Errorf("provider key: %w", err) + } + + price, ok := new(big.Int).SetString(p.PricePerMBDay, 10) + if !ok { + return nil, fmt.Errorf("incorrect amount format") + } + + prs = append(prs, client.NewProviderData{ + Address: address.NewAddress(0, 0, keyBytes), + MaxSpan: p.MaxSpan, + PricePerMBDay: tlb.FromNanoTON(price), + }) + } + + addr, body, si, err := a.client.BuildAddProviderTransaction(a.globalCtx, hashBytes, owner, prs) + if err != nil { + return nil, err + } + + return &Transaction{ + Body: base64.StdEncoding.EncodeToString(body), + StateInit: base64.StdEncoding.EncodeToString(si), + Address: addr.Bounce(false).String(), + Amount: amt.Nano().String(), + }, nil +} + +func (a *API) BuildWithdrawalContractData(hash, ownerAddr string) (*Transaction, error) { + hashBytes, err := toHashBytes(hash) + if err != nil { + return nil, err + } + + owner, err := address.ParseAddr(ownerAddr) + if err != nil { + return nil, err + } + + addr, body, err := a.client.BuildWithdrawalTransaction(hashBytes, owner) + if err != nil { + return nil, err + } + + return &Transaction{ + Body: base64.StdEncoding.EncodeToString(body), + StateInit: "", + Address: addr.Bounce(true).String(), + Amount: tlb.MustFromTON("0.03").Nano().String(), + }, nil +} + func toHashBytes(hash string) ([]byte, error) { hashBytes, err := hex.DecodeString(hash) if err != nil { diff --git a/core/client/storage.go b/core/client/storage.go index 3f07f31..d0f9142 100644 --- a/core/client/storage.go +++ b/core/client/storage.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/base64" "fmt" + "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/liteclient" "github.com/xssnick/tonutils-go/tl" "log" @@ -316,3 +317,23 @@ func (s *StorageClient) SetSpeedLimits(ctx context.Context, download, upload int } return fmt.Errorf("unexpected response") } + +func (s *StorageClient) FetchProviderContract(ctx context.Context, torrentHash []byte, owner *address.Address) (*ProviderContractData, error) { + return nil, fmt.Errorf("not supported with storage daemon") +} + +func (s *StorageClient) FetchProviderRates(ctx context.Context, torrentHash, providerKey []byte) (*ProviderRates, error) { + return nil, fmt.Errorf("not supported with storage daemon") +} + +func (s *StorageClient) RequestProviderStorageInfo(ctx context.Context, torrentHash, providerKey []byte, owner *address.Address) (*ProviderStorageInfo, error) { + return nil, fmt.Errorf("not supported with storage daemon") +} + +func (s *StorageClient) BuildAddProviderTransaction(ctx context.Context, torrentHash []byte, owner *address.Address, providers []NewProviderData) (addr *address.Address, bodyData, stateInit []byte, err error) { + return nil, nil, nil, fmt.Errorf("not supported with storage daemon") +} + +func (s *StorageClient) BuildWithdrawalTransaction(torrentHash []byte, owner *address.Address) (addr *address.Address, bodyData []byte, err error) { + return nil, nil, fmt.Errorf("not supported with storage daemon") +} diff --git a/core/client/types.go b/core/client/types.go index 7f8061b..a89dc6e 100644 --- a/core/client/types.go +++ b/core/client/types.go @@ -1,13 +1,17 @@ package client import ( + "context" "encoding/binary" "fmt" + "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/tl" "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/tvm/cell" + "github.com/xssnick/tonutils-storage-provider/pkg/contract" "github.com/xssnick/tonutils-storage/storage" "math" + "time" ) func init() { @@ -213,6 +217,39 @@ type MetaFile struct { Header *storage.TorrentHeader } +type NewProviderData struct { + Address *address.Address + MaxSpan uint32 + PricePerMBDay tlb.Coins +} + +type ProviderContractData struct { + Size uint64 + Address *address.Address + Providers []contract.ProviderDataV1 + Balance tlb.Coins +} + +type ProviderRates struct { + Available bool + RatePerMBDay tlb.Coins + MinBounty tlb.Coins + SpaceAvailableMB uint64 + MinSpan uint32 + MaxSpan uint32 + + Size uint64 +} + +type ProviderStorageInfo struct { + Status string + Reason string + Progress float64 + + Context context.Context + FetchedAt time.Time +} + func (t *Torrent) Parse(data []byte) (_ []byte, err error) { // Manual parse because of not standard array definition if len(data) < 36 { diff --git a/core/gostorage/client.go b/core/gostorage/client.go index c491683..25c5036 100644 --- a/core/gostorage/client.go +++ b/core/gostorage/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/ed25519" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -14,6 +15,8 @@ import ( "github.com/xssnick/tonutils-go/adnl/dht" "github.com/xssnick/tonutils-go/liteclient" "github.com/xssnick/tonutils-go/tl" + "github.com/xssnick/tonutils-go/ton" + "github.com/xssnick/tonutils-storage-provider/pkg/transport" "github.com/xssnick/tonutils-storage/config" "github.com/xssnick/tonutils-storage/db" "github.com/xssnick/tonutils-storage/storage" @@ -21,6 +24,8 @@ import ( "net" "os" "sort" + "sync" + "time" ) type Config struct { @@ -35,10 +40,17 @@ type Config struct { type Client struct { storage *db.Storage connector storage.NetConnector + api ton.APIClientWrapped + provider *transport.Client + + infoCache map[string]*client.ProviderStorageInfo + mx sync.RWMutex } func NewClient(dbPath string, cfg Config) (*Client, error) { - c := &Client{} + c := &Client{ + infoCache: map[string]*client.ProviderStorageInfo{}, + } ldb, err := leveldb.OpenFile(dbPath, nil) if err != nil { @@ -61,7 +73,7 @@ func NewClient(dbPath string, cfg Config) (*Client, error) { os.Exit(1) } } else { - lsCfg, err = liteclient.GetConfigFromUrl(context.Background(), "https://ton.org/global.config.json") + lsCfg, err = liteclient.GetConfigFromUrl(context.Background(), "https://ton.org/testnet-global.config.json") if err != nil { pterm.Warning.Println("Failed to download ton config:", err.Error(), "; We will take it from static cache") lsCfg = &liteclient.GlobalConfig{} @@ -72,18 +84,31 @@ func NewClient(dbPath string, cfg Config) (*Client, error) { } } + lsPool := liteclient.NewConnectionPool() + c.api = ton.NewAPIClient(lsPool, ton.ProofCheckPolicyFast) + + // connect async to not slow down main processes + go func() { + for { + if err := lsPool.AddConnectionsFromConfig(context.Background(), lsCfg); err != nil { + pterm.Warning.Println("Failed to add connections from ton config:", err.Error()) + time.Sleep(5 * time.Second) + continue + } + break + } + }() + gate := adnl.NewGateway(cfg.Key) serverMode := ip != nil if serverMode { gate.SetExternalIP(ip) - err = gate.StartServer(cfg.ListenAddr) - if err != nil { + if err = gate.StartServer(cfg.ListenAddr); err != nil { return nil, fmt.Errorf("failed to start adnl gateway in server mode: %w", err) } } else { - err = gate.StartClient() - if err != nil { + if err = gate.StartClient(); err != nil { return nil, fmt.Errorf("failed to start adnl gateway in client mode: %w", err) } } @@ -98,6 +123,14 @@ func NewClient(dbPath string, cfg Config) (*Client, error) { return nil, fmt.Errorf("failed to init dht client: %w", err) } + providerGateSeed := sha256.Sum256(cfg.Key.Seed()) + gateKey := ed25519.NewKeyFromSeed(providerGateSeed[:]) + gateProvider := adnl.NewGateway(gateKey) + if err = gateProvider.StartClient(); err != nil { + return nil, fmt.Errorf("failed to start adnl gateway for provider: %w", err) + } + c.provider = transport.NewClient(gateProvider, dhtClient) + downloadGate := adnl.NewGateway(cfg.Key) if err = downloadGate.StartClient(); err != nil { return nil, fmt.Errorf("failed to init downloader gateway: %w", err) diff --git a/core/gostorage/provider.go b/core/gostorage/provider.go new file mode 100644 index 0000000..a7ef8f2 --- /dev/null +++ b/core/gostorage/provider.go @@ -0,0 +1,273 @@ +package gostorage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/tonutils/torrent-client/core/client" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/tvm/cell" + "github.com/xssnick/tonutils-storage-provider/pkg/contract" + "log" + "math" + "math/big" + "math/rand" + "os" + "strings" + "time" +) + +func (c *Client) FetchProviderContract(ctx context.Context, torrentHash []byte, owner *address.Address) (*client.ProviderContractData, error) { + t := c.storage.GetTorrent(torrentHash) + if t == nil { + return nil, fmt.Errorf("torrent is not found") + } + if t.Info == nil { + return nil, fmt.Errorf("info is not downloaded") + } + + addr, _, _, err := contract.PrepareV1DeployData(torrentHash, t.Info.RootHash, t.Info.FileSize, t.Info.PieceSize, owner, nil) + if err != nil { + return nil, fmt.Errorf("failed to calc contract addr: %w", err) + } + + master, err := c.api.CurrentMasterchainInfo(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch master block: %w", err) + } + + list, balance, err := contract.GetProvidersV1(ctx, c.api, master, addr) + if err != nil { + if errors.Is(err, contract.ErrNotDeployed) { + return nil, contract.ErrNotDeployed + } + return nil, fmt.Errorf("failed to fetch providers list: %w", err) + } + + return &client.ProviderContractData{ + Size: t.Info.FileSize, + Address: addr, + Providers: list, + Balance: balance, + }, nil +} + +func (c *Client) BuildAddProviderTransaction(ctx context.Context, torrentHash []byte, owner *address.Address, providers []client.NewProviderData) (addr *address.Address, bodyData, stateInit []byte, err error) { + t := c.storage.GetTorrent(torrentHash) + if t == nil { + return nil, nil, nil, fmt.Errorf("torrent is not found") + } + if t.Info == nil { + return nil, nil, nil, fmt.Errorf("info is not downloaded") + } + + var prs []contract.ProviderV1 + for _, p := range providers { + prs = append(prs, contract.ProviderV1{ + Address: p.Address, + MaxSpan: p.MaxSpan, + PricePerMBDay: p.PricePerMBDay, + }) + } + + addr, si, body, err := contract.PrepareV1DeployData(torrentHash, t.Info.RootHash, t.Info.FileSize, t.Info.PieceSize, owner, prs) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to prepare contract data: %w", err) + } + + siCell, err := tlb.ToCell(si) + if err != nil { + return nil, nil, nil, fmt.Errorf("serialize state init: %w", err) + } + return addr, body.ToBOC(), siCell.ToBOC(), nil +} + +func (c *Client) BuildWithdrawalTransaction(torrentHash []byte, owner *address.Address) (addr *address.Address, bodyData []byte, err error) { + t := c.storage.GetTorrent(torrentHash) + if t == nil { + return nil, nil, fmt.Errorf("torrent is not found") + } + if t.Info == nil { + return nil, nil, fmt.Errorf("info is not downloaded") + } + + addr, body, err := contract.PrepareWithdrawalRequest(torrentHash, t.Info.RootHash, t.Info.FileSize, t.Info.PieceSize, owner) + if err != nil { + return nil, nil, fmt.Errorf("failed to prepare contract data: %w", err) + } + + return addr, body.ToBOC(), nil +} + +func (c *Client) FetchProviderRates(ctx context.Context, torrentHash, providerKey []byte) (*client.ProviderRates, error) { + t := c.storage.GetTorrent(torrentHash) + if t == nil { + return nil, fmt.Errorf("torrent is not found") + } + if t.Info == nil { + return nil, fmt.Errorf("info is not downloaded") + } + + rates, err := c.provider.GetStorageRates(ctx, providerKey, t.Info.FileSize) + if err != nil { + switch { + case strings.Contains(err.Error(), "value is not found"): + return nil, errors.New("provider is not found") + case strings.Contains(err.Error(), "context deadline exceeded"): + return nil, errors.New("provider is not respond in a given time") + } + return nil, fmt.Errorf("failed to get rates: %w", err) + } + + return &client.ProviderRates{ + Available: rates.Available, + RatePerMBDay: tlb.FromNanoTON(new(big.Int).SetBytes(rates.RatePerMBDay)), + MinBounty: tlb.FromNanoTON(new(big.Int).SetBytes(rates.MinBounty)), + SpaceAvailableMB: rates.SpaceAvailableMB, + MinSpan: rates.MinSpan, + MaxSpan: rates.MaxSpan, + Size: t.Info.FileSize, + }, nil +} + +func (c *Client) RequestProviderStorageInfo(ctx context.Context, torrentHash, providerKey []byte, owner *address.Address) (*client.ProviderStorageInfo, error) { + t := c.storage.GetTorrent(torrentHash) + if t == nil { + return nil, fmt.Errorf("torrent is not found") + } + if t.Info == nil { + return nil, fmt.Errorf("info is not downloaded") + } + + addr, _, _, err := contract.PrepareV1DeployData(torrentHash, t.Info.RootHash, t.Info.FileSize, t.Info.PieceSize, owner, nil) + if err != nil { + return nil, fmt.Errorf("failed to calc contract addr: %w", err) + } + + c.mx.Lock() + defer c.mx.Unlock() + + var tm time.Time + v := c.infoCache[addr.String()] + if v != nil { + tm = v.FetchedAt + } else { + v = &client.ProviderStorageInfo{ + Status: "connecting...", + } + c.infoCache[addr.String()] = v + } + + // run job if result is older than 10 sec and no another active job + if time.Since(tm) > 5*time.Second && (v.Context == nil || v.Context.Err() != nil) { + var end func() + v.Context, end = context.WithCancel(context.Background()) + + go func() { + defer end() + + proofByte := uint64(rand.Int63()) % t.Info.FileSize + info, err := c.provider.RequestStorageInfo(ctx, providerKey, addr, proofByte) + if err != nil { + log.Println("failed to get storage info:", err) + + c.mx.Lock() + c.infoCache[addr.String()] = &client.ProviderStorageInfo{ + Status: "inactive", + Reason: err.Error(), + FetchedAt: time.Now(), + } + c.mx.Unlock() + return + } + + json.NewEncoder(os.Stdout).Encode(info) + + progress, _ := new(big.Float).Quo(new(big.Float).SetUint64(info.Downloaded), new(big.Float).SetUint64(t.Info.FileSize)).Float64() + + if info.Status == "active" { + proved := false + // verify proof + proof, err := cell.FromBOC(info.Proof) + if err == nil { + if proofData, err := cell.UnwrapProof(proof, t.Info.RootHash); err == nil { + piece := uint32(proofByte / uint64(t.Info.PieceSize)) + pieces := uint32(t.Info.FileSize / uint64(t.Info.PieceSize)) + + if err = checkProofBranch(proofData, piece, pieces); err == nil { + info.Reason = fmt.Sprintf("Storage proof received just now") + proved = true + } + } else { + log.Println("failed to unwrap proof:", err) + } + } + + if !proved { + info.Status = "untrusted" + info.Reason = "Incorrect proof received" + } + } else if info.Status == "downloading" { + info.Reason = fmt.Sprintf("Progress: %.2f", progress*100) + "%" + } else if info.Status == "resolving" { + info.Reason = fmt.Sprintf("Provider is trying to find source to download bag") + } else if info.Status == "warning-balance" { + info.Reason = fmt.Sprintf("Not enough balance to store bag, please topup or it will be deleted soon") + } + + c.mx.Lock() + c.infoCache[addr.String()] = &client.ProviderStorageInfo{ + Status: info.Status, + Reason: info.Reason, + Progress: progress * 100, + FetchedAt: time.Now(), + } + c.mx.Unlock() + }() + } + + return v, nil +} + +func checkProofBranch(proof *cell.Cell, piece, piecesNum uint32) error { + if piece >= piecesNum { + return fmt.Errorf("piece is out of range %d/%d", piece, piecesNum) + } + + tree := proof.BeginParse() + + // calc tree depth + depth := int(math.Log2(float64(piecesNum))) + if piecesNum > uint32(math.Pow(2, float64(depth))) { + // add 1 if pieces num is not exact log2 + depth++ + } + + // check bits from left to right and load branches + for i := depth - 1; i >= 0; i-- { + isLeft := piece&(1< + + + + \ No newline at end of file diff --git a/frontend/public/light/logout.svg b/frontend/public/light/logout.svg new file mode 100644 index 0000000..328f414 --- /dev/null +++ b/frontend/public/light/logout.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea910a1..e32c2c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,16 @@ import {InfoTorrentMenu} from "./components/InfoTorrentMenu"; import {SettingsModal} from "./components/ModalSettings"; import {RemoveConfirmModal} from "./components/ModalRemoveConfirm"; import {ProvidersTorrentMenu} from "./components/ProvidersTorrentMenu"; +import {TonConnectButton} from "@tonconnect/ui-react"; +import {AddProviderModal} from "./components/ModalAddProvider"; +import {DoTxModal} from "./components/ModalDoTx"; + +interface DoProviderTxModalData { + hash: string + owner: string + providers: any[] + justTopup: boolean +} interface State { isDark: boolean @@ -26,6 +36,8 @@ interface State { tableFilter: Filter showAddTorrentModal: boolean showCreateTorrentModal: boolean + showAddProviderModal: boolean + showDoTransactionModal: boolean showSettingsModal: boolean showRemoveConfirmModal: boolean @@ -36,7 +48,9 @@ interface State { ready: boolean openFileHash?: string + addProviderTorrentHash?: string removeHashes?: string[] + doProviderTxModalData?: DoProviderTxModalData } export class App extends Component<{}, State> { @@ -55,6 +69,8 @@ export class App extends Component<{}, State> { showCreateTorrentModal: false, showSettingsModal: false, showRemoveConfirmModal: false, + showAddProviderModal: false, + showDoTransactionModal: false, overallUploadSpeed: "0 Bytes", overallDownloadSpeed: "0 Bytes", torrentMenuSelected: -1, @@ -95,11 +111,29 @@ export class App extends Component<{}, State> { toggleRemoveConfirmModal = () => { this.setState((current)=>({...current, showRemoveConfirmModal: !this.state.showRemoveConfirmModal, removeHashes: undefined})) } + toggleAddProviderModal = () => { + this.setState((current)=>({...current, showAddProviderModal: false, addProviderTorrentHash: undefined})) + } + toggleDoTransactionModal = () => { + this.setState((current)=>({...current, showDoTransactionModal: false, doProviderTxModalData: undefined})) + } async componentDidMount() { let dark = await IsDarkTheme(); this.setState((current)=>({...current, isDark: dark})) + EventsOn("want_add_provider", (torrentHash: string) => { + this.setState((current)=>({...current, showAddProviderModal: true, addProviderTorrentHash: torrentHash})) + }) + EventsOn("want_set_providers", (torrentHash: string, owner: string, providers: any[], justTopup: boolean) => { + this.setState((current)=>({...current, showDoTransactionModal: true, doProviderTxModalData: { + hash: torrentHash, + owner, + providers, + justTopup + } + })) + }) EventsOn("want_remove_torrent", (hashes: string[]) => { this.setState((current)=>({...current, removeHashes: hashes, showRemoveConfirmModal: true})) }) @@ -184,6 +218,7 @@ export class App extends Component<{}, State> { document.documentElement.style.setProperty("--theme-img", "url(../dark/theme.svg)"); document.documentElement.style.setProperty("--copy-img", "url(../dark/copy.svg)"); document.documentElement.style.setProperty("--expand-img", "url(../dark/expand.svg)"); + document.documentElement.style.setProperty("--logout-img", "url(../dark/logout.svg)"); } else { document.documentElement.style.setProperty('--back', "#FFFFFF"); document.documentElement.style.setProperty('--table-back', "#F7F9FB"); @@ -210,10 +245,12 @@ export class App extends Component<{}, State> { document.documentElement.style.setProperty("--theme-img", "url(../light/theme.svg)"); document.documentElement.style.setProperty("--copy-img", "url(../light/copy.svg)"); document.documentElement.style.setProperty("--expand-img", "url(../light/expand.svg)"); + document.documentElement.style.setProperty("--logout-img", "url(../light/logout.svg)"); } return (
+
@@ -223,6 +260,8 @@ export class App extends Component<{}, State> { {this.state.showCreateTorrentModal ? : null} {this.state.showSettingsModal ? : null} {this.state.showRemoveConfirmModal ? : null} + {this.state.showAddProviderModal ? : null} + {this.state.showDoTransactionModal ? : null}
diff --git a/frontend/src/assets/images/icons/ton.svg b/frontend/src/assets/images/icons/ton.svg new file mode 100644 index 0000000..63f67b0 --- /dev/null +++ b/frontend/src/assets/images/icons/ton.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/base.scss b/frontend/src/base.scss index fa05b46..42adec0 100644 --- a/frontend/src/base.scss +++ b/frontend/src/base.scss @@ -726,7 +726,7 @@ img { } .files-table { - width: 100%; + width: calc(100% - 20px); margin-top: 5px; margin-left: 10px; margin-right: 10px; @@ -765,6 +765,143 @@ img { } } +.tonconnect-button { + color: red; + width: 30px; +} + +button[data-tc-connect-button="true"] { + box-shadow: 0 0 0 0; + height: 36px; + + display: flex; + padding: 6px 16px; + justify-content: center; + align-items: center; + background: var(--accent-default, $ton); + + border-radius: 24px; + border: 1px solid var(--button-stroke, $buttonStroke); + + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 24px; + letter-spacing: -0.008px; + + cursor: pointer; + transition: none; +} + +button[data-tc-connect-button="true"]:hover { + background: var(--accent-hover, #00A6FF); + transform: none; +} + +.providers-connect { + display: flex; + flex-direction: column; + margin-bottom: 10px; + margin-top: 10px; +} + +.providers-login { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.providers-login .menu-item { + width: 188px; + height: 36px; + + display: flex; + justify-content: center; + align-items: center; + background: none; + + border-radius: 24px; + border: 1px solid var(--button-stroke, $buttonStroke); + margin: auto auto; + + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 24px; + letter-spacing: -0.008px; + + color: var(--text-primary, $text-black); + cursor: pointer; +} + +.providers-login .main { + color: #fff; + margin: 5px auto 10px; + + background: var(--accent-default, $ton); +} + +.providers-login .main:hover { + background: var(--accent-hover, #00A6FF); +} + +.providers-login .main::before { + display: block; + height: 25px; + width: 27px; + content: ''; + background-image: url(assets/images/icons/ton.svg); +} + +.removed-provider td { + color: $secondaryText; + text-decoration: line-through; +} + +.new-provider td { + color: $ton; +} + +.providers-connect button[icon-type="logout"] { + border: 0; + outline: inherit; + background: var(--logout-img) 0 0 transparent; + background-size: cover; + width: 15px; + height: 15px; + margin-left: 4px; +} + +.providers-connect button[icon-type="copy"] { + border: 0; + outline: inherit; + background: var(--copy-img) 0 0 transparent; + background-size: cover; + width: 17px; + height: 17px; + margin-left: 4px; +} +.providers-connect button[icon-type="copy"].clicked { + border: 0; + outline: inherit; + background: url(assets/images/icons/copy-done.svg) 0 0 transparent; + background-size: cover; + width: 17px; + height: 17px; + margin-left: 4px; +} + +.providers-connect button[icon-type="remove"] { + border: 0; + outline: inherit; + background: var(--close-img) 0 0 transparent; + width: 16px; + height: 16px; + background-size: cover; +} + #App { height: 100%; display: flex; diff --git a/frontend/src/components/FilesTorrentMenu.tsx b/frontend/src/components/FilesTorrentMenu.tsx index a0be0cf..d466e44 100644 --- a/frontend/src/components/FilesTorrentMenu.tsx +++ b/frontend/src/components/FilesTorrentMenu.tsx @@ -93,7 +93,7 @@ export class FilesTorrentMenu extends Component { Name Size Downloaded - Progress + Progress diff --git a/frontend/src/components/ModalAddProvider.tsx b/frontend/src/components/ModalAddProvider.tsx new file mode 100644 index 0000000..162734d --- /dev/null +++ b/frontend/src/components/ModalAddProvider.tsx @@ -0,0 +1,96 @@ +import React, {Component} from 'react'; +import {baseModal} from "./Modal"; +import { + CancelCreateTorrent, + CreateTorrent, + ExportMeta, + FetchProviderRates, + OpenDir, + OpenFolderSelectFile +} from "../../wailsjs/go/main/App"; +import {EventsEmit, EventsOff, EventsOn} from "../../wailsjs/runtime"; + +interface State { + fetchStage: boolean + canContinue: boolean + + providerKey: string + err?: string +} + +interface AddProviderModalProps { + hash: string + onExit: () => void; +} + +export class AddProviderModal extends Component { + constructor(props: AddProviderModalProps) { + super(props); + this.state = { + err: "", + fetchStage: false, + canContinue: false, + providerKey: "", + } + } + + componentDidMount() { + EventsOn("provider-connected", () => { + this.props.onExit() + }) + } + componentWillUnmount() { + EventsOff("provider-connected") + } + + next = () => { + this.setState((current) => ({ ...current, canContinue: false, fetchStage: true })); + + console.log(this.props.hash+" -- "+ this.state.providerKey) + FetchProviderRates(this.props.hash, this.state.providerKey).then((rates) => { + console.log(rates); + + if (rates.Success) { + EventsEmit("provider-added", rates.Provider, this.props.hash); + this.props.onExit(); + } else { + this.setState((current) => ({...current, err: rates.Reason, canContinue: true, fetchStage: false})); + } + }); + } + + render() { + return baseModal(this.props.onExit, ( + <> +
+ Connecting to provider... +
+
+
+
+
+ Add Provider + { + let val = e.currentTarget.value; + let can = Boolean(val.length == 64 && val.match(/^[0-9a-f]+$/i)); + this.setState((current) => ({...current, name: val, canContinue: can, providerKey: can ? val : ""})); + }}/> + {this.state.err ? {this.state.err} : ""} +
+ {(this.state.fetchStage) ?
+ +
: +
+ + +
} + + )); + } +} \ No newline at end of file diff --git a/frontend/src/components/ModalDoTx.tsx b/frontend/src/components/ModalDoTx.tsx new file mode 100644 index 0000000..3d1870d --- /dev/null +++ b/frontend/src/components/ModalDoTx.tsx @@ -0,0 +1,90 @@ +import React, {Component, useState} from 'react'; +import {baseModal} from "./Modal"; +import { + BuildProviderContractData, + CancelCreateTorrent, + CreateTorrent, + ExportMeta, + FetchProviderRates, + OpenDir, + OpenFolderSelectFile +} from "../../wailsjs/go/main/App"; +import {EventsEmit, EventsOff, EventsOn} from "../../wailsjs/runtime"; +import {useTonAddress, useTonConnectUI, useTonWallet} from "@tonconnect/ui-react"; +import {ProvidersProps} from "./ProvidersTorrentMenu"; + +interface State { + canContinue: boolean + amount: string +} + +interface DoTxModalProps { + hash: string + owner: string + providers: any[] + amount?: string + justTopup: boolean + onExit: () => void; +} + +export const DoTxModal: React.FC = (props) => { + const [state, setState] = useState({ + canContinue: true, + amount: props.amount ?? "0.5", + }); + + const [tonConnectUI] = useTonConnectUI(); + return baseModal(props.onExit, ( + <> +
+ Contract deposit + { + let val = e.currentTarget.value; + let m = val.match(/([0-9]*[.])?[0-9]+/); + if (m && m[0] == val) { + let f = parseFloat(val); + if (f && f > 0.05) { + setState((current) => ({ + ...current, + name: val, + canContinue: true, + amount: f.toFixed(6) + })); + return; + } + } + setState((current) => ({...current, name: val, canContinue: false})); + }}/> +
+
+ + +
+ + )); +} \ No newline at end of file diff --git a/frontend/src/components/ProvidersTorrentMenu.tsx b/frontend/src/components/ProvidersTorrentMenu.tsx index dd5a3b7..730ce0a 100644 --- a/frontend/src/components/ProvidersTorrentMenu.tsx +++ b/frontend/src/components/ProvidersTorrentMenu.tsx @@ -1,13 +1,31 @@ -import React, {Component} from 'react'; -import {EventsOff, EventsOn} from "../../wailsjs/runtime"; -import {GetPeers} from "../../wailsjs/go/main/App"; -import {TonConnectButton, TonConnectUI} from "@tonconnect/ui-react"; - -export interface ProviderItem { - ip: string - adnl: string - uploadSpeed: string - downloadSpeed: string +import React, {Component, useEffect, useState} from 'react'; +import {EventsEmit, EventsOff, EventsOn, WindowSetMinSize} from "../../wailsjs/runtime"; +import { + BuildProviderContractData, BuildWithdrawalContractData, + CheckHeader, + GetFiles, + GetPeers, + GetProviderContract +} from "../../wailsjs/go/main/App"; +import { + TonConnectButton, + TonConnectUI, + useTonAddress, + useTonConnectModal, + useTonConnectUI, + useTonWallet +} from "@tonconnect/ui-react"; +import {textState} from "./Table"; + +export interface Provider { + id: string + lastProof: string + proofEvery: string + price: string + status: string + reason: string + type: 'new' | 'committed' | 'removed' + data: any } export interface ProvidersProps { @@ -15,67 +33,296 @@ export interface ProvidersProps { } interface State { - providers: ProviderItem[] + address: string + fetched: boolean + providers: Provider[]; + contractBalance: string } -export class ProvidersTorrentMenu extends Component { - constructor(props: ProvidersProps, state:State) { - super(props, state); +function copy(text: string) { + return (clicked: any) => { + navigator.clipboard.writeText(text).then(); + clicked.target.classList.add('clicked'); + setTimeout(() => { + clicked.target.classList.remove('clicked'); + },500); + } +} + +export const ProvidersTorrentMenu: React.FC = (props) => { + const [state, setState] = useState({ + providers: [], + fetched: false, + address: '', + contractBalance: '0 TON' + }); + + let address = useTonAddress(true); + const [tonConnectUI] = useTonConnectUI(); + + + useEffect(() => { + setState((current) => { + return {...current, fetched: false, address: "", providers: [], contractBalance: "0 TON"} + }); + + let fetchContract = () => { + if (!address) return; + + GetProviderContract(props.torrent, address).then(provider => { + if (!provider.Success) { + return + } - this.state = { - providers: [], + if (!provider.Deployed) { + setState((current) => { + return {...current, providers: current.providers.filter(v => v.type == 'new'), fetched: true, address: ""} + }); + return; + } + + setState((current) => { + let ps: Provider[] = []; + if (provider.Providers) { + for (const p of provider.Providers) { + let curI = current.providers.findIndex(v => v.id == p.Key); + + if (curI != -1 && current.providers[curI].type == 'new') { + // it is not new anymore + current.providers[curI].type = 'committed'; + } + + ps.push({ + id: p.Key, + lastProof: p.LastProof, + proofEvery: p.Span, + price: p.PricePerDay, + status: p.Status, + reason: p.Reason, + type: curI != -1 ? current.providers[curI].type : 'committed', + data: p.Data, + }); + } + } + // push new + ps.push(...current.providers.filter(v => v.type == 'new')); + + return {...current, providers: ps, fetched: true, contractBalance: provider.Balance, address: provider.Address} + }); + }); } - } - update() { - /* GetProviders(this.props.torrent).then((tr: any)=>{ - let newList: ProviderItem[] = [] - tr.forEach((t: any)=> { - newList.push({ - ip: t.IP, - adnl: t.ADNL, - uploadSpeed: t.Upload, - downloadSpeed: t.Download, - }) - }) - - this.setState({ - providers: newList + EventsOn("provider-added", (p: any, hash: string) => { + if (hash != props.torrent) { + return; + } + + setState((current)=> { + if (current.providers.find(v => v.id == p.Key)) { + return current; + } + + current.providers.push({ + id: p.Key, + lastProof: p.LastProof, + proofEvery: p.Span, + price: p.PricePerDay, + status: "", + reason: "", + type: 'new', + data: p.Data, + }); + + return {...current, providers: current.providers} }); - });*/ - } + }); - componentDidMount() { - this.update() - EventsOn("update_providers", () => { - this.update(); + EventsOn("select-torrent", (hash: string) => { + props.torrent = hash; + fetchContract(); }) - } - componentWillUnmount() { - EventsOff("update_providers") - TonConnectUI.getWallets().then((walletsList) => { - console.log(walletsList); - }); + fetchContract(); + let inter = window.setInterval(fetchContract, 3000); + + tonConnectUI.onModalStateChange((s) => { + if (s.status == 'closed') { + WindowSetMinSize(800, 487); + } else if (s.status == 'opened') { + WindowSetMinSize(800, 720); + } + }) + + return () => { + EventsOff("provider-added"); + EventsOff("select-torrent"); + clearInterval(inter); + }; + }, [props.torrent,address]); + + const statusSwitch = (status: string) => { + if (status === 'error') { + return 'fail'; + } else if (status === 'downloading') { + return 'downloading'; + } else if (status === 'active') { + return 'seeding'; + } else if (status === 'resolving' || status.startsWith('warning-')) { + return 'searching'; + } else { + return 'inactive'; + } } - renderPeersList() { - let items = []; + const renderProvidersList = () => { + let items: any[] = []; + + for (const [i, t] of state.providers.entries()) { + let cl = ""; + if (t.type == 'removed') { + cl = "removed-provider" + } else if (t.type == 'new') { + cl = "new-provider"; + } - for (let t of this.state.providers) { - items.push( - {t.ip} - {t.adnl} - {t.downloadSpeed} - {t.uploadSpeed} - ); + let status = t.status; + if (status.startsWith('warning-')) { + status = status.slice(8) + } + + items.push( + + {t.id} + { t.type == 'new' ? '' :
{ + let tip = document.getElementById("tip"); + tip!.textContent = t.reason != "" ? t.reason.charAt(0).toUpperCase() + t.reason.slice(1) : status.charAt(0).toUpperCase() + status.slice(1); + if (status == 'inactive') { + tip!.textContent = "Not connected" + } + let rectItem = document.getElementById("state-"+t.id)!.getBoundingClientRect() + let rectTip = tip!.getBoundingClientRect(); + + tip!.style.top = (rectItem.y - (rectTip.height + 12)).toString()+"px"; + tip!.style.left = (rectItem.x - (rectTip.width/2 - rectItem.width/2)).toString()+"px"; + + tip!.style.opacity = "1"; + tip!.style.visibility = "visible"; + }} onMouseLeave={ + (e)=> { + let tip = document.getElementById("tip"); + tip!.style.opacity = "0"; + tip!.style.visibility = "hidden"; + } + }>
}{status == 'inactive' ? "Proof: "+t.lastProof : status.charAt(0).toUpperCase() + status.slice(1)} + {t.proofEvery} + {t.price} +
+
Authorized:
+
{shortAddr}
+
+
+ { !state.fetched ? : <> } + { state.providers.length > 0 ? + + + + + + + + + + + {renderProvidersList()} + +
Provider keyStateProof everyPrice per day
: "" } +
+ + + +
+
: + +
} + ); +}; \ No newline at end of file diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx index bfdac97..cacc8d3 100644 --- a/frontend/src/components/Table.tsx +++ b/frontend/src/components/Table.tsx @@ -146,6 +146,7 @@ export class Table extends Component { // report selected to callback let selected = this.state.torrents.filter((tr)=>{return tr.selected}); this.props.onSelect(selected.map((ti) => { + EventsEmit("select-torrent", ti.id); return { hash: ti.id, active: ti.state == "downloading" || ti.state == "seeding", @@ -261,7 +262,8 @@ export class Table extends Component { {t.progress}%
-
+ + {t.size} {t.peersNum} @@ -274,7 +276,6 @@ export class Table extends Component { render() { return -