diff --git a/client/core/bond.go b/client/core/bond.go index 1640ca61e9..eab1e09539 100644 --- a/client/core/bond.go +++ b/client/core/bond.go @@ -458,6 +458,10 @@ func (c *Core) bondStateOfDEX(dc *dexConnection, bondCfg *dexBondCfg) *dexAcctBo return state } +func (c *Core) exchangeAuth(dc *dexConnection) *ExchangeAuth { + return &c.bondStateOfDEX(dc, c.dexBondConfig(dc, time.Now().Unix())).ExchangeAuth +} + type bondID struct { assetID uint32 coinID []byte @@ -908,7 +912,7 @@ func (c *Core) monitorBondConfs(dc *dexConnection, bond *asset.Bond, reqConfs ui if confs < reqConfs { details := fmt.Sprintf("Bond confirmations %v/%v", confs, reqConfs) c.notify(newBondPostNoteWithConfirmations(TopicRegUpdate, string(TopicRegUpdate), - details, db.Data, assetID, coinIDStr, int32(confs), host)) + details, db.Data, assetID, coinIDStr, int32(confs), host, c.exchangeAuth(dc))) } return confs >= reqConfs, nil @@ -982,12 +986,12 @@ func (c *Core) nextBondKey(assetID uint32) (*secp256k1.PrivateKey, uint32, error // the target trading tier, the preferred asset to use for bonds, and the // maximum amount allowable to be locked in bonds. func (c *Core) UpdateBondOptions(form *BondOptionsForm) error { - dc, _, err := c.dex(form.Addr) + dc, _, err := c.dex(form.Host) if err != nil { return err } // TODO: exclude unregistered and/or watch-only - dbAcct, err := c.db.Account(form.Addr) + dbAcct, err := c.db.Account(form.Host) if err != nil { return err } @@ -1015,8 +1019,14 @@ func (c *Core) UpdateBondOptions(form *BondOptionsForm) error { } }() + var success bool dc.acct.authMtx.Lock() - defer dc.acct.authMtx.Unlock() + defer func() { + dc.acct.authMtx.Unlock() + if success { + c.notify(newBondAuthUpdate(dc.acct.host, c.exchangeAuth(dc))) + } + }() if !dc.acct.isAuthed { return errors.New("login or register first") @@ -1025,7 +1035,6 @@ func (c *Core) UpdateBondOptions(form *BondOptionsForm) error { // Revert to initial values if we encounter any error below. bondAssetID0 = dc.acct.bondAsset targetTier0, maxBondedAmt0, penaltyComps0 = dc.acct.targetTier, dc.acct.maxBondedAmt, dc.acct.penaltyComps - var success bool defer func() { // still under authMtx lock on defer stack if !success { dc.acct.bondAsset = bondAssetID0 @@ -1054,7 +1063,10 @@ func (c *Core) UpdateBondOptions(form *BondOptionsForm) error { dbAcct.TargetTier = targetTier } - penaltyComps := form.PenaltyComps + var penaltyComps = penaltyComps0 + if form.PenaltyComps != nil { + penaltyComps = *form.PenaltyComps + } dc.acct.penaltyComps = penaltyComps dbAcct.PenaltyComps = penaltyComps @@ -1169,6 +1181,7 @@ func (c *Core) UpdateBondOptions(form *BondOptionsForm) error { success = true } // else we might have already done ReserveBondFunds... return err + } // BondsFeeBuffer suggests how much extra may be required for the transaction @@ -1530,7 +1543,7 @@ func (c *Core) makeAndPostBond(dc *dexConnection, acctExists bool, wallet *xcWal details := fmt.Sprintf("Waiting for %d confirmations to post bond %v (%s) to %s", reqConfs, bondCoinStr, unbip(bond.AssetID), dc.acct.host) // TODO: subject, detail := c.formatDetails(...) c.notify(newBondPostNoteWithConfirmations(TopicBondConfirming, string(TopicBondConfirming), - details, db.Success, bond.AssetID, bondCoinStr, 0, dc.acct.host)) + details, db.Success, bond.AssetID, bondCoinStr, 0, dc.acct.host, c.exchangeAuth(dc))) // Set up the coin waiter, which watches confirmations so the user knows // when to expect their account to be marked paid by the server. c.monitorBondConfs(dc, bond, reqConfs) @@ -1591,7 +1604,8 @@ func (c *Core) bondConfirmed(dc *dexConnection, assetID uint32, coinID []byte, p } c.log.Infof("Bond %s (%s) confirmed.", bondIDStr, unbip(assetID)) details := fmt.Sprintf("New tier = %d (target = %d).", effectiveTier, targetTier) // TODO: format to subject,details - c.notify(newBondPostNoteWithTier(TopicBondConfirmed, string(TopicBondConfirmed), details, db.Success, dc.acct.host, bondedTier)) + + c.notify(newBondPostNoteWithTier(TopicBondConfirmed, string(TopicBondConfirmed), details, db.Success, dc.acct.host, bondedTier, c.exchangeAuth(dc))) } else if !foundConfirmed { c.log.Errorf("bondConfirmed: Bond %s (%s) not found", bondIDStr, unbip(assetID)) // just try to authenticate... @@ -1617,7 +1631,7 @@ func (c *Core) bondConfirmed(dc *dexConnection, assetID uint32, coinID []byte, p details := fmt.Sprintf("New tier = %d", effectiveTier) // TODO: format to subject,details c.notify(newBondPostNoteWithTier(TopicAccountRegistered, string(TopicAccountRegistered), - details, db.Success, dc.acct.host, bondedTier)) // possibly redundant with SubjectBondConfirmed + details, db.Success, dc.acct.host, bondedTier, c.exchangeAuth(dc))) // possibly redundant with SubjectBondConfirmed return nil } @@ -1669,7 +1683,7 @@ func (c *Core) bondExpired(dc *dexConnection, assetID uint32, coinID []byte, not if int64(targetTier) > effectiveTier { details := fmt.Sprintf("New tier = %d (target = %d).", effectiveTier, targetTier) c.notify(newBondPostNoteWithTier(TopicBondExpired, string(TopicBondExpired), - details, db.WarningLevel, dc.acct.host, bondedTier)) + details, db.WarningLevel, dc.acct.host, bondedTier, c.exchangeAuth(dc))) } return nil diff --git a/client/core/core.go b/client/core/core.go index 89c95219d0..19d753276c 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -339,6 +339,7 @@ func coreMarketFromMsgMarket(dc *dexConnection, msgMkt *msgjson.Market) *Market QuoteID: quote.ID, QuoteSymbol: quote.Symbol, LotSize: msgMkt.LotSize, + ParcelSize: msgMkt.ParcelSize, RateStep: msgMkt.RateStep, EpochLen: msgMkt.EpochLen, StartEpoch: msgMkt.StartEpoch, @@ -519,7 +520,8 @@ func (c *Core) exchangeInfo(dc *dexConnection) *Exchange { CandleDurs: cfg.BinSizes, ViewOnly: dc.acct.isViewOnly(), Auth: acctBondState.ExchangeAuth, - // TODO: Bonds + MaxScore: cfg.MaxScore, + PenaltyThreshold: cfg.PenaltyThreshold, // Legacy reg fee (V0PURGE) RegFees: feeAssets, diff --git a/client/core/core_test.go b/client/core/core_test.go index 1c48872911..a030f6531c 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -250,6 +250,7 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection, Base: tUTXOAssetA.ID, Quote: tUTXOAssetB.ID, LotSize: dcrBtcLotSize, + ParcelSize: 100, RateStep: dcrBtcRateStep, EpochLen: 60000, MarketBuyBuffer: 1.1, @@ -10575,7 +10576,7 @@ func TestUpdateBondOptions(t *testing.T) { name: "set target tier to 1", bal: singlyBondedReserves, form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTier, BondAssetID: &bondAsset.ID, }, @@ -10589,7 +10590,7 @@ func TestUpdateBondOptions(t *testing.T) { name: "low balance", bal: singlyBondedReserves - 1, form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTier, BondAssetID: &bondAsset.ID, }, @@ -10599,7 +10600,7 @@ func TestUpdateBondOptions(t *testing.T) { name: "max-bonded too low", bal: singlyBondedReserves, form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTier, BondAssetID: &bondAsset.ID, MaxBondedAmt: &tooLowMaxBonded, @@ -10609,7 +10610,7 @@ func TestUpdateBondOptions(t *testing.T) { { name: "unsupported bond asset", form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTier, BondAssetID: &wrongBondAssetID, }, @@ -10619,7 +10620,7 @@ func TestUpdateBondOptions(t *testing.T) { name: "lower target tier with zero balance OK", bal: 0, form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTierZero, BondAssetID: &bondAsset.ID, }, @@ -10634,7 +10635,7 @@ func TestUpdateBondOptions(t *testing.T) { name: "lower target tier to zero with other exchanges still keeps reserves", bal: 0, form: BondOptionsForm{ - Addr: acct.host, + Host: acct.host, TargetTier: &targetTierZero, BondAssetID: &bondAsset.ID, }, @@ -10775,7 +10776,7 @@ func TestRotateBonds(t *testing.T) { // if the locktime is not too soon. acct.bonds = append(acct.bonds, acct.pendingBonds[0]) acct.pendingBonds = nil - acct.bonds[0].LockTime = mergeableLocktimeThresh + 1 + acct.bonds[0].LockTime = mergeableLocktimeThresh + 5 rig.queuePrevalidateBond() run(1, 0, 2*bondAsset.Amt+bondFeeBuffer) mergingBond := acct.pendingBonds[0] diff --git a/client/core/exchangeratefetcher.go b/client/core/exchangeratefetcher.go index b943452768..68c800ea49 100644 --- a/client/core/exchangeratefetcher.go +++ b/client/core/exchangeratefetcher.go @@ -35,7 +35,7 @@ const ( var ( dcrDataURL = "https://explorer.dcrdata.org/api/exchangerate" // coinpaprika has two options. /tickers is for the top 2500 assets all in - // one request. /ticker/[slug] is for a single ticker. From testing + // one request. /tickers/[slug] is for a single ticker. From testing // Single ticker request took 274.626125ms // Size of single ticker response: 0.733 kB // All tickers request took 47.651851ms @@ -48,7 +48,7 @@ var ( // So any more than 25000 / 3600 = 6.9 assets, and we can expect to run into // rate limits. But the bandwidth of the full tickers request is kinda // ridiculous too. Solution needed. - coinpaprikaURL = "https://api.coinpaprika.com/v1/tickers/%s" + coinpaprikaURL = "https://api.coinpaprika.com/v1/tickers" // The best info I can find on Messari says // Without an API key requests are rate limited to 20 requests per minute // and 1000 requests per day. @@ -142,26 +142,12 @@ func newCommonRateSource(fetcher rateFetcher) *commonRateSource { // for sample request and response information. func FetchCoinpaprikaRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 { fiatRates := make(map[uint32]float64) - fetchRate := func(sa *SupportedAsset) { - assetID := sa.ID - if sa.Wallet == nil { - // we don't want to fetch rates for assets with no wallet. - return - } - - res := new(struct { - Quotes struct { - Currency struct { - Price float64 `json:"price"` - } `json:"USD"` - } `json:"quotes"` - }) - + slugAssets := make(map[string]uint32) + for _, sa := range assets { symbol := dex.TokenSymbol(sa.Symbol) if symbol == "dextt" { - return + continue } - name := sa.Name // TODO: Store these within the *SupportedAsset. switch symbol { @@ -171,21 +157,37 @@ func FetchCoinpaprikaRates(ctx context.Context, log dex.Logger, assets map[uint3 symbol = "matic" name = "polygon" } + slug := coinpapSlug(symbol, name) + slugAssets[slug] = sa.ID + } - reqStr := fmt.Sprintf(coinpaprikaURL, coinpapSlug(symbol, name)) - - ctx, cancel := context.WithTimeout(ctx, fiatRequestTimeout) - defer cancel() + ctx, cancel := context.WithTimeout(ctx, fiatRequestTimeout) + defer cancel() - if err := getRates(ctx, reqStr, res); err != nil { - log.Errorf("Error getting fiat exchange rates from coinpaprika: %v", err) - return - } + var res []*struct { + ID string `json:"id"` + Quotes struct { + USD struct { + Price float64 `json:"price"` + } `json:"USD"` + } `json:"quotes"` + } - fiatRates[assetID] = res.Quotes.Currency.Price + if err := getRates(ctx, coinpaprikaURL, &res); err != nil { + log.Errorf("Error getting fiat exchange rates from coinpaprika: %v", err) + return fiatRates } - for _, sa := range assets { - fetchRate(sa) + for _, coinInfo := range res { + assetID, found := slugAssets[coinInfo.ID] + if !found { + continue + } + price := coinInfo.Quotes.USD.Price + if price == 0 { + log.Errorf("zero-price returned from coinpaprika for slug %s", coinInfo.ID) + continue + } + fiatRates[assetID] = price } return fiatRates } @@ -288,6 +290,6 @@ func getRates(ctx context.Context, url string, thing any) error { return fmt.Errorf("error %d fetching %q", resp.StatusCode, url) } - reader := io.LimitReader(resp.Body, 1<<20) + reader := io.LimitReader(resp.Body, 1<<22) return json.NewDecoder(reader).Decode(thing) } diff --git a/client/core/notification.go b/client/core/notification.go index cb5bbf3721..83b6b9111a 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -265,14 +265,19 @@ func newBondRefundNote(topic Topic, subject, details string, severity db.Severit } } +const ( + TopicBondAuthUpdate Topic = "BondAuthUpdate" +) + // BondPostNote is a notification regarding bond posting. type BondPostNote struct { db.Notification - Asset *uint32 `json:"asset,omitempty"` - Confirmations *int32 `json:"confirmations,omitempty"` - BondedTier *int64 `json:"bondedTier,omitempty"` - CoinID *string `json:"coinID,omitempty"` - Dex string `json:"dex,omitempty"` + Asset *uint32 `json:"asset,omitempty"` + Confirmations *int32 `json:"confirmations,omitempty"` + BondedTier *int64 `json:"bondedTier,omitempty"` + CoinID *string `json:"coinID,omitempty"` + Dex string `json:"dex,omitempty"` + Auth *ExchangeAuth `json:"auth,omitempty"` } func newBondPostNote(topic Topic, subject, details string, severity db.Severity, dexAddr string) *BondPostNote { @@ -283,20 +288,39 @@ func newBondPostNote(topic Topic, subject, details string, severity db.Severity, } } -func newBondPostNoteWithConfirmations(topic Topic, subject, details string, severity db.Severity, asset uint32, coinID string, currConfs int32, dexAddr string) *BondPostNote { - bondPmtNt := newBondPostNote(topic, subject, details, severity, dexAddr) +func newBondPostNoteWithConfirmations( + topic Topic, + subject string, + details string, + severity db.Severity, + asset uint32, + coinID string, + currConfs int32, + host string, + auth *ExchangeAuth, +) *BondPostNote { + + bondPmtNt := newBondPostNote(topic, subject, details, severity, host) bondPmtNt.Asset = &asset bondPmtNt.CoinID = &coinID bondPmtNt.Confirmations = &currConfs + bondPmtNt.Auth = auth return bondPmtNt } -func newBondPostNoteWithTier(topic Topic, subject, details string, severity db.Severity, dexAddr string, bondedTier int64) *BondPostNote { +func newBondPostNoteWithTier(topic Topic, subject, details string, severity db.Severity, dexAddr string, bondedTier int64, auth *ExchangeAuth) *BondPostNote { bondPmtNt := newBondPostNote(topic, subject, details, severity, dexAddr) bondPmtNt.BondedTier = &bondedTier + bondPmtNt.Auth = auth return bondPmtNt } +func newBondAuthUpdate(host string, auth *ExchangeAuth) *BondPostNote { + n := newBondPostNote(TopicBondAuthUpdate, "", "", db.Data, host) + n.Auth = auth + return n +} + // SendNote is a notification regarding a requested send or withdraw. type SendNote struct { db.Notification @@ -673,8 +697,8 @@ func newWalletNote(n asset.WalletNotification) *WalletNote { type ReputationNote struct { db.Notification - Host string - Reputation account.Reputation + Host string `json:"host"` + Reputation account.Reputation `json:"rep"` } const TopicReputationUpdate = "ReputationUpdate" diff --git a/client/core/types.go b/client/core/types.go index 7b6155fc18..ea21e8bc80 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -188,10 +188,10 @@ type SupportedAsset struct { // BondOptionsForm is used from the settings page to change the auto-bond // maintenance setting for a DEX. type BondOptionsForm struct { - Addr string `json:"host"` + Host string `json:"host"` TargetTier *uint64 `json:"targetTier,omitempty"` MaxBondedAmt *uint64 `json:"maxBondedAmt,omitempty"` - PenaltyComps uint16 `json:"penaltyComps"` + PenaltyComps *uint16 `json:"penaltyComps,omitempty"` BondAssetID *uint32 `json:"bondAssetID,omitempty"` } @@ -525,6 +525,7 @@ type Market struct { QuoteID uint32 `json:"quoteid"` QuoteSymbol string `json:"quotesymbol"` LotSize uint64 `json:"lotsize"` + ParcelSize uint32 `json:"parcelsize"` RateStep uint64 `json:"ratestep"` EpochLen uint64 `json:"epochlen"` StartEpoch uint64 `json:"startepoch"` @@ -693,7 +694,8 @@ type Exchange struct { CandleDurs []string `json:"candleDurs"` ViewOnly bool `json:"viewOnly"` Auth ExchangeAuth `json:"auth"` - // TODO: Bonds slice(s) - and a LockedInBonds(assetID) method + PenaltyThreshold uint32 `json:"penaltyThreshold"` + MaxScore uint32 `json:"maxScore"` // OLD fields for the legacy registration fee (V0PURGE): RegFees map[string]*FeeAsset `json:"regFees"` diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index 4dab0a2432..fb7a7ef109 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -507,7 +507,7 @@ func parseBondOptsArgs(params *RawParams) (*core.BondOptionsForm, error) { } } - var penaltyComps uint16 + var penaltyComps *uint16 if len(params.Args) > 4 { pc, err := checkIntArg(params.Args[4], "penaltyComps", 16) if err != nil { @@ -517,12 +517,13 @@ func parseBondOptsArgs(params *RawParams) (*core.BondOptionsForm, error) { return nil, fmt.Errorf("penaltyComps out of range (0, %d)", math.MaxUint16) } if pc > 0 { - penaltyComps = uint16(pc) + pc16 := uint16(pc) + penaltyComps = &pc16 } } req := &core.BondOptionsForm{ - Addr: params.Args[0], + Host: params.Args[0], TargetTier: targetTierP, MaxBondedAmt: maxBondedP, BondAssetID: bondAssetP, diff --git a/client/webserver/locales/ar.go b/client/webserver/locales/ar.go index b7517ce9e8..1e7a182da4 100644 --- a/client/webserver/locales/ar.go +++ b/client/webserver/locales/ar.go @@ -178,7 +178,6 @@ var Ar = map[string]string{ "All markets at": "جميع الأسواق في", "pick a different asset": "اختر أصلًا مختلفًا", "Create": "انشاء", - "Register_loudly": "التسجيل!", "1 Sync the Blockchain": "1: مزامنة سلسلة الكتل", "Progress": "قيد التنفيذ", "remaining": "الوقت المتبقي", diff --git a/client/webserver/locales/de-de.go b/client/webserver/locales/de-de.go index a4e669b8a9..2807134eca 100644 --- a/client/webserver/locales/de-de.go +++ b/client/webserver/locales/de-de.go @@ -169,11 +169,9 @@ var DeDE = map[string]string{ "Export Trades": "Exportiere Trades", "change the wallet type": "den Wallet-Typ ändern", "confirmations": "Bestätigungen", - "how_reg": "Wie soll die Anmeldegebühr bezahlt werden?", "All markets at": "Alle Märkte bei", "pick a different asset": "ein anderes Asset wählen", "Create": "Erstellen", - "Register_loudly": "Registrieren!", "1 Sync the Blockchain": "1: Blockchain synchronisieren", "Progress": "Fortschritt", "remaining": "verbleibend", diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 49a6c6d9f2..a35e5b03cc 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -28,13 +28,17 @@ var EnUS = map[string]string{ "Skip Registration": "No account (view-only mode)", "Confirm Registration": "Confirm Registration and Bonding", "app_pw_reg": "Enter your app password to confirm DEX registration and bond creation.", - "reg_confirm_submit": `When you submit this form, funds will be spent from your wallet to post a fidelity bond, which is redeemable by you in the future.`, + "reg_confirm_submit": `When you submit this form, funds from your wallet will be temporarily locked into a fidelity bond contract, which is redeemable by you in the future.`, "bond_strength": "Bond Strength", - "update_bond_options": "Update Bond Options", - "bond_options": "Bond Options", - "bond_options_update_success": "Bond Options have been updated successfully", "target_tier": "Target Tier", "target_tier_tooltip": "This is the target account tier you wish to maintain. Set to zero if you wish to disable tier maintenance (do not post new bonds).", + "compensation_tooltip": "Enable posting additional bonds to offset penalized tiers.", + "Actual Tier": "Actual Tier", + "Penalties": "Penalties", + "Change Tier": "Change Tier", + "Limit Bonus": "Limit Bonus", + "Score": "Score", + "Confirm Bond Options": "Confirm Bond Options", "provided_markets": "This DEX provides the following markets:", "accepted_fee_assets": "This DEX recognizes the bond assets:", "base_header": "Base", @@ -195,22 +199,32 @@ var EnUS = map[string]string{ "Export Trades": "Export Trades", "change the wallet type": "change the wallet type", "confirmations": "confirmations", - "how_reg": "How will you create your bond?", "All markets at": "All markets at", "pick a different asset": "pick a different asset", "Create": "Create", - "Register_loudly": "Register!", "1 Sync the Blockchain": "1: Sync the Blockchain", "Progress": "Progress", "remaining": "remaining", "2 Fund your Wallet": "2: Fund your Wallet", - "whatsabond": "Fidelity bonds are time-locked funds redeemable only by you, but in the future. This is meant to combat disruptive behavior like backing out on swaps.", + "bond_definition": "A fidelity bond is funds temporarily locked in an on-chain contract. After the contract expires, your wallet will reclaim the funds. On mainnet, funds are locked for 2 months.", + "bonds_can_be_revoked": "Bonds can be revoked if an account engages in continued disruptive behavior, such as backing out on a swap. Revoked bonds can be re-activated with continued normal trading activity.", + "bigger_bonds_higher_limit": "You can create larger bonds to increase your trading tier, enabling trading of larger quantities at a time. Larger bonds also increase your capacity for violations before trading privileges are suspended.", + "limits_reputation": "Trading limits are also increased as you establish reputation by engaging in normal trading activity and successfully completing matches.", + "wallet_bond_reserves": "Your wallet will automatically reserve the funds necessary to keep you bonded at your selected trading tier, and will broadcast new bonds to replace expiring ones. You can lower or raise your trading tier in the exchange's settings panel. Set your trading tier to zero to disable your account (once your existing bonds expire).", + "Got it": "Got it", + "Trading Limits": "Trading Limits", + "What is a fidelity bond": "What is a fidelity bond?", + "order_form_remaining_limit": ` lots remaining in tier trading limit`, + "Parcel Size": "Parcel Size", + "Trading Limit": "Trading Limit", + "Current Usage": "Current Usage", + "score_factors": "Increase your score by successfully completing trades. Failure to act on a trade will decrease your score.", "Bond amount": "Bond amount", "Reserves for tx fees": "Funds to reserve for transaction fees to maintain your bonds", "Tx Fee Balance": "Transaction Fee Balance:", "Your Deposit Address": "Your Wallet's Deposit Address", "Send enough for bonds": `Make sure you send enough to also cover network fees. You may deposit as much as you like to your wallet, since only the bond amount will be used in the next step. The deposit must confirm to proceed.`, - "Send enough with estimate": `Deposit at least XYZ to cover network fees and overlap periods when a bond is expired (but waiting for refund) and another must be posted. You may deposit as much as you like to your wallet, since only the required amount will be used in the next step. The deposit must confirm to proceed.`, + "Send enough with estimate": `Deposit at least XYZ to cover your bond and fees. You may deposit as much as you like to your wallet, since only the required amount will be used in the next step. The wallet may require a confirmation on the new funds before proceeding.`, "Send funds for token": `Deposit at least XYZ and XYZ to also cover fees. You may deposit as much as you like to your wallet, since only the required amount will be used in the next step. The deposit must confirm to proceed.`, "add a different server": "add a different server", "Add a custom server": "Add a custom server", @@ -276,6 +290,16 @@ var EnUS = map[string]string{ "Settings": "Settings", "asset_name Markets": " Markets", "Host": "Host", + "Trading Tier": "Trading Tier", + "Bond Lock": "Bond Lock", + "USD": "USD", + "Fee Reserves": "Fee Reserves", + "Select your bond asset": "Select your bond asset", + "choose a different asset": "choose a different asset", + "current_bonding_asset": `Using for bonding`, + "Choose your trading tier": "Choose your trading tier", + "trading_tier_message": "Increase your trading tier to enable trading of larger amounts. Trading limits also grow with reputation.", + "Other Trading Limits": "Other Trading Limits", "No Recent Activity": "No Recent Activity", "Recent asset_name Activity": "Recent Activity", "other_actions": "Other Actions", @@ -433,15 +457,8 @@ var EnUS = map[string]string{ "fiat_rates": "Fiat Rates", "market_making_running": "Market making is running", "cannot_manually_trade": "You cannot manually place orders while market making is running", - "bond_details": "Bond Details", - "current_tier": "Current Tier", - "current_tier_tooltip": "Number of active bonds that have not yet reached the expiry threshold as reported by the DEX server. Increase your target tier to raise your account tier, boost your trading limits, and offset penalties, if any.", - "current_target_tier_tooltip": "This is the target account tier you wish to maintain. If zero, bond maintenance will be disabled and new bonds will not be posted.", - "current_target_tier": "Current Target Tier", - "bond_cost": "Bond Cost", - "bond_cost_tooltip": "Cost of a single bond without fees and bond maintenance fund reservation.", - "bond_reservations": "Bond Reservation", - "bond_reservations_tooltip": "Total funds that will be locked when you post a bond to cover fees and bond maintenance costs.", + "back": "Back", + "current_tier_tooltip": "Tier represented by active bonds. Increase your target tier to raise your target tier, boost your trading limits, and offset penalties, if any.", "Reset App Password": "Reset App Password", "reset_app_pw_msg": "Reset your app password using your app seed. If you provide the correct app seed, you can login again with the new password.", "Forgot Password": "Forgot Password?", diff --git a/client/webserver/locales/pl-pl.go b/client/webserver/locales/pl-pl.go index cb20991119..781a32dc95 100644 --- a/client/webserver/locales/pl-pl.go +++ b/client/webserver/locales/pl-pl.go @@ -168,7 +168,6 @@ var PlPL = map[string]string{ "All markets at": "Wszystkie rynki na", "pick a different asset": "wybierz inne aktywo", "Create": "Utwórz", - "Register_loudly": "Zarejestruj!", "1 Sync the Blockchain": "1: Zsynchronizuj blockchain", "Progress": "Postęp", "remaining": "pozostało", diff --git a/client/webserver/locales/pt-br.go b/client/webserver/locales/pt-br.go index 7a7aba9715..beebf5e96c 100644 --- a/client/webserver/locales/pt-br.go +++ b/client/webserver/locales/pt-br.go @@ -169,7 +169,6 @@ var PtBr = map[string]string{ "All markets at": "Todos mercados", "pick a different asset": "Escolher ativo diferente", "Create": "Criar", - "Register_loudly": "Registre!", "1 Sync the Blockchain": "1: Sincronizar a Blockchain", "Progress": "Progresso", "remaining": "Faltando", diff --git a/client/webserver/locales/zh-cn.go b/client/webserver/locales/zh-cn.go index 71f8411a99..ac9894fee2 100644 --- a/client/webserver/locales/zh-cn.go +++ b/client/webserver/locales/zh-cn.go @@ -171,7 +171,6 @@ var ZhCN = map[string]string{ "All markets at": "所有市场", "pick a different asset": "选择其它的资产", "Create": "创建", - "Register_loudly": "注册!", "1 Sync the Blockchain": "1: 同步区块链", "Progress": "进度", "remaining": "剩余", diff --git a/client/webserver/site/src/css/dex_settings.scss b/client/webserver/site/src/css/dex_settings.scss index e114c4a09a..e930ebd150 100644 --- a/client/webserver/site/src/css/dex_settings.scss +++ b/client/webserver/site/src/css/dex_settings.scss @@ -6,3 +6,7 @@ color: green; } +#penaltyCompInput { + width: 2rem; +} + diff --git a/client/webserver/site/src/css/forms.scss b/client/webserver/site/src/css/forms.scss index 0c29b24f44..f66eb820fe 100644 --- a/client/webserver/site/src/css/forms.scss +++ b/client/webserver/site/src/css/forms.scss @@ -1,18 +1,5 @@ #regAssetForm { - #whatsabond { - max-width: 425px; - } - - div.reg-asset-allmkts { - min-width: 210px; - max-width: 320px; - } - div.reg-asset { - min-width: 425px; - padding: 20px 10px; - border-bottom: dashed 2px #7777; - .fader { position: absolute; bottom: 0; @@ -44,11 +31,6 @@ } } - img.reg-asset-logo { - width: 50px; - height: 50px; - } - img.reg-market-logo { width: 14px; height: 14px; @@ -59,7 +41,7 @@ @extend .stylish-overflow; display: block; - max-height: 65px; + max-height: 120px; line-height: 1.15; overflow-y: hidden; margin-right: 8px; @@ -83,13 +65,6 @@ } } - div.reg-asset-details { - box-sizing: content-box; - line-height: 1.25; - width: 100px; - padding: 0 50px 0 25px; - } - div.reg-assets-markets-wrap { position: relative; @@ -102,6 +77,25 @@ color: #c7c3cc; } + .reg-asset-table { + thead > tr > th { + border-top: 1px solid; + } + + th, + td { + border-color: $light_border_color; + border-width: 1px; + border-style: none solid solid; + padding: 5px; + text-align: left; + } + } + + input[data-tmpl=regAssetTier] { + width: 3em; + } + .readygreen { color: #009931; } @@ -125,11 +119,6 @@ } } - img.logo { - width: 30px; - height: 30px; - } - input.app-pass { // margin: 0 auto; display: inline-block; @@ -143,6 +132,10 @@ div.borderright { border-right: 1px solid #777; } + + .mw50 { + max-width: 50%; + } } #newWalletForm { @@ -275,11 +268,6 @@ div[data-handler=register] { width: 25px; } - .borderleft { - padding-left: 25px; - border-left: solid 1px #777; - } - .logo { width: 40px; height: 40px; @@ -339,8 +327,7 @@ div[data-handler=register] { #vSendForm, #exportSeedAuth, #cancelForm, -#quickConfigForm, -#bondDetailsForm { +#quickConfigForm { width: 375px; } @@ -422,9 +409,78 @@ a[data-tmpl=walletCfgGuide] { } } +div[data-tmpl=scoreTray] { + background-color: $buycolor_dark; + height: 12px; + border-radius: 100px; + overflow: hidden; + + div[data-tmpl=scoreWarn] { + background-color: $sellcolor_dark; + position: absolute; + top: 0; + bottom: 0; + left: 0; + } +} + +span[data-tmpl=scorePointer] { + transform: translateX(-50%); + + div[data-tmpl=scoreData] { + top: 0; + bottom: 0; + + &.positive { + right: 150%; + } + + &.negative { + left: 150%; + } + } +} + +.penalty-marker { + position: absolute; + top: 0; + bottom: 0; + left: 10%; + width: 2px; + z-index: 2; + background-color: black; +} + div[data-handler=init] { .quickconfig-asset-logo { width: 25px; height: 25px; } } + +.anitoggle { + width: 1.5rem; + height: 0.9rem; + border-radius: 0.45rem; + background-color: #777a; + cursor: pointer; + + &.on { + background-color: $buycolor_dark; + } + + & > div { + position: relative; + top: 0.1rem; + left: 0.1rem; + width: 0.7rem; + height: 0.7rem; + border-radius: 0.35rem; + transition: left 0.5s; + background-color: $dark_body_bg; + } + + &.on > div { + left: 0.7rem; + } +} \ No newline at end of file diff --git a/client/webserver/site/src/css/forms_dark.scss b/client/webserver/site/src/css/forms_dark.scss index 2adc2b6f77..dc11f48a80 100644 --- a/client/webserver/site/src/css/forms_dark.scss +++ b/client/webserver/site/src/css/forms_dark.scss @@ -31,6 +31,12 @@ body.dark { } } } + + .reg-asset-table { + th, td { + border-color: $dark_border_color; + } + } } ::-webkit-calendar-picker-indicator { diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index c64525a626..844a69e208 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -445,7 +445,7 @@ div.popup-notes { } hr.dashed { - border-top: 1px dashed #777; + border-top: dashed 2px #777; } .vscroll { diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index b34c804d06..6b823b01de 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -585,7 +585,7 @@ div[data-handler=markets] { &.selected { opacity: 1; - background-color: #e8ebed; + background-color: #0001; div.opt-check { background-color: #2cce9c; diff --git a/client/webserver/site/src/css/market_dark.scss b/client/webserver/site/src/css/market_dark.scss index 765bb373f2..81d3d57336 100644 --- a/client/webserver/site/src/css/market_dark.scss +++ b/client/webserver/site/src/css/market_dark.scss @@ -87,8 +87,6 @@ body.dark { } #orderForm { - color: #a1a1a1; - button { color: #aaa; } @@ -137,7 +135,7 @@ body.dark { } .order-opt.selected { - background-color: #222e38; + background-color: #fff1; } } diff --git a/client/webserver/site/src/css/settings.scss b/client/webserver/site/src/css/settings.scss index 360977ddc1..8c27894c6f 100644 --- a/client/webserver/site/src/css/settings.scss +++ b/client/webserver/site/src/css/settings.scss @@ -1,11 +1,10 @@ div.settings { - display: inline-block; - width: 500px; - text-align: left; + min-width: 375px; & > div { - position: relative; - padding: 10px; + width: 100%; + text-align: left; + padding: 10px 0; border-bottom: 1px solid #7777; } @@ -13,8 +12,8 @@ div.settings { border-top: 1px solid #7777; } - & > div.form-check { - padding-left: 35px; + div.form-check { + padding-left: 25px; } button { diff --git a/client/webserver/site/src/html/dexsettings.tmpl b/client/webserver/site/src/html/dexsettings.tmpl index fded08256a..f02214247f 100644 --- a/client/webserver/site/src/html/dexsettings.tmpl +++ b/client/webserver/site/src/html/dexsettings.tmpl @@ -1,35 +1,86 @@ {{define "dexsettings"}} {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}} -
-
- -
-
-
{{.Exchange.Host}}
-
- - - -
-
-
- - -
-
- +
+
+
+
-
- +
+
{{.Exchange.Host}}
+
+ + +
-
- - - [[[successful_cert_update]]] +
+
+
+
+
+
+ [[[target_tier]]] + +
+
+ [[[Actual Tier]]] + +
+
+ [[[Penalties]]] + +
+
+
+
+ +
+
+
+
+
+
+ Auto Renew +
+
+
+
+
+
+
+
+
+ Penalty Comps + +
+
+ + +
+
+
+
+
+
+ {{template "reputationMeter"}} +
-
- +
+
+ + +
+
+ +
+
+ + + [[[successful_cert_update]]] +
+
+ +
@@ -49,64 +100,37 @@ {{template "dexAddrForm" .}} - {{- /* BOND DETAILS */ -}} -
-
-
[[[bond_details]]]
-
- - [[[current_tier]]] - -
- -
- - [[[current_target_tier]]] - -
- -
-
-
- - [[[bond_cost]]] - -
- - -
- - ~USD - -
- - [[[bond_reservations]]] - -
- - -
- - ~USD - -
-
[[[bond_options]]]:
-
- - + {{- /* SUCCESS ANIMATION */ -}} + +
+
-
- - -
-
- -
-
-
[[[bond_options_update_success]]]
+
+ + + {{- /* REG ASSET SELECTOR */ -}} +
+ {{template "regAssetForm"}} +
+ + {{- /* CONFIRM POST BOND */ -}} +
+ {{template "confirmRegistrationForm"}} +
+ + {{- /* SYNC AND BALANCE FORM */ -}} +
+ {{template "waitingForWalletForm"}} +
+ + {{- /* Form to set up a wallet. Shown on demand when a user clicks a setupWallet button. */ -}} +
+ {{template "newWalletForm" }} +
+ + {{- /* UNLOCK WALLET */ -}} +
+ {{template "unlockWalletForm"}}
diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index 9721355021..2499f67dbe 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -215,7 +215,7 @@ {{define "discoverAcctForm"}}
[[[Create Account]]]
-
on
+
@@ -227,66 +227,142 @@ {{end}} {{define "regAssetForm"}} -
[[[how_reg]]]
-
- - [[[whatsabond]]] -
-
-
-
- +
+
+ +
+
[[[What is a fidelity bond]]]
+
    +
  • + [[[bond_definition]]] +
  • +
  • + [[[bonds_can_be_revoked]]] +
  • +
  • + [[[bigger_bonds_higher_limit]]] +
  • +
  • + [[[limits_reputation]]] +
  • +
  • + [[[wallet_bond_reserves]]] +
  • +
+
+
[[[Got it]]]
+
+
+
+
+
+ [[[Choose your trading tier]]] + [[[trading_tier_message]]]
-
-
-
[[[confirmations]]]
-
-
-
- - - - - - - - - - - - - -
[[[Market]]][[[:title:lot_size]]]
-
- - - -
-
- - - -
-
+
+ +
-
-
-
-
[[[All markets at]]]
- +
+
+
+
[[[current_bonding_asset]]]
+
[[[choose a different asset]]]
-
- +
+
[[[Select your bond asset]]]
+
What's a bond?
+
- - - + + + + - + + + + + + + +
[[[Market]]][[[:title:lot_size]]]
[[[Asset]]][[[Bond Lock]]][[[Trading Limits]]]
+
+
+ + +
+
+
+
+
+
+ + +
+
+ ~ + + [[[USD]]] +
+
+
+
+
+ + + + +
+
+ + ~ + + [[[USD]]] + +
+
+
[[[Other Trading Limits]]]
+ + +
-
+
+
+
[[[All markets at]]]
+ +
+
+ + + + + + + + + + + + + +
[[[Market]]][[[:title:lot_size]]]
+
+ + - +
+
+ + + +
+
+
+
+
{{end}} @@ -312,44 +388,52 @@ {{end}} {{define "confirmRegistrationForm"}} - [[[pick a different asset]]] -
- [[[reg_confirm_submit]]] -
- -
- -
- -
-
- -
-
- -
- -
- -
- - - - - - [[[plus tx fees]]] +
+ [[[pick a different asset]]] +
[[[Confirm Bond Options]]]
+
+
+
+ [[[Host]]] + +
+
+ [[[Trading Tier]]] + +
+
+ [[[Bond Lock]]] + + + + + +
+
+ + ~ [[[USD]]] +
+
+ [[[Fee Reserves]]] + +
+
+
+
+ [[[app_pw_reg]]] + +
+
+ +
+
+
+
+ [[[reg_confirm_submit]]] +
- -
- [[[app_pw_reg]]] - -
-
- -
-
{{end}} {{define "authorizeAccountExportForm"}} @@ -523,7 +607,7 @@ {{define "waitingForWalletForm"}}
-
+
@@ -543,7 +627,7 @@
-
+
@@ -553,19 +637,18 @@
- [[[Balance]]]: + [[[Available Balance]]]: XYZ
-
- - [[[whatsabond]]] -
- [[[Bond amount]]]: + Bond Lock: XYZ
+
+ Includes for your bond and for transaction fees. +
[[[Send enough with estimate]]] {{- /* NOTE: includes totalForBond */}} [[[Send funds for token]]]
@@ -664,3 +747,31 @@
{{end}} + +{{define "reputationMeter"}} +
+
+ + + [[[Limit Bonus]]] + x + + +
+
+
+
+
+
+ + + [[[Score]]]: + + + +
+
+ [[[score_factors]]] +
+
+{{end}} diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index a82e86f99d..674929945c 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -378,6 +378,13 @@
{{/* textContent set by script */}}
+ + {{- /* ORDER LIMITS */ -}} +
+ [[[order_form_remaining_limit]]] +
+ +
@@ -414,6 +421,55 @@
+ {{- /* REPUTATION */ -}} +
+
+ +
+ [[[Parcel Size]]] + + [[[lots]]] + +
+ +
+ + + + +
+ +
+ + + ~ + +
+ +
+
+ +
+ [[[Trading Tier]]] + +
+ +
+ [[[Trading Limit]]] + lots +
+ +
+ [[[Current Usage]]] + % +
+ +
+
+
+ {{template "reputationMeter"}} +
+ {{- /* USER ORDERS */ -}}
[[[Your Orders]]] diff --git a/client/webserver/site/src/html/register.tmpl b/client/webserver/site/src/html/register.tmpl index 72e0b53061..cf10d040d7 100644 --- a/client/webserver/site/src/html/register.tmpl +++ b/client/webserver/site/src/html/register.tmpl @@ -29,12 +29,12 @@ {{- /* REG ASSET SELECTOR */ -}} -
+ {{template "regAssetForm"}}
{{- /* CONFIRM FEE PAYMENT */ -}} -
+ {{template "confirmRegistrationForm"}}
diff --git a/client/webserver/site/src/html/settings.tmpl b/client/webserver/site/src/html/settings.tmpl index 02cf568a4e..d3886603ba 100644 --- a/client/webserver/site/src/html/settings.tmpl +++ b/client/webserver/site/src/html/settings.tmpl @@ -3,80 +3,82 @@ {{$passwordIsCached := .UserInfo.PasswordIsCached}} {{$authed := .UserInfo.Authed}}
-
-
-
- - -
-
- Fiat Currency: {{.FiatCurrency}} -
-
- - [[[fiat_exchange_rate_sources]]]: - - - {{range $source, $enabled := .FiatRateSources}} -
- - -
- {{end}} -
-
-
-
[[[registered dexes]]]
- {{range $host, $xc := .Exchanges}} - - {{end}} +
+
+
+
+ +
-
-
-

- [[[simultaneous_servers_msg]]] -

- - +
+ Fiat Currency: {{.FiatCurrency}}
-
-
- - -
-
- - -
-
-
-
- +
+ + [[[fiat_exchange_rate_sources]]]: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
+
+
+
[[[registered dexes]]]
+ {{range $host, $xc := .Exchanges}} + + {{end}}
-
[[[browser_ntfn_blocked]]]
-
-
-
+
+ + +
+
+ + +
+
+ +
+
-
- -
- - [[[seed_implore_msg]]]
- +
[[[browser_ntfn_blocked]]]
+
+
+ +
+
+ +
+ + [[[seed_implore_msg]]]
+ +
+
[[[Build ID]]]:
-
[[[Build ID]]]:
{{- /* POP-UP FORMS */ -}} @@ -88,11 +90,11 @@ {{- /* REG ASSET SELECTOR */ -}} -
+ {{template "regAssetForm"}}
{{- /* CONFIRM REGISTRATION */ -}} -
+ {{template "confirmRegistrationForm"}}
diff --git a/client/webserver/site/src/js/account.ts b/client/webserver/site/src/js/account.ts new file mode 100644 index 0000000000..3e6478116a --- /dev/null +++ b/client/webserver/site/src/js/account.ts @@ -0,0 +1,159 @@ +import Doc from './doc' +import { + OrderTypeLimit, + OrderTypeMarket, + OrderTypeCancel, + StatusEpoch, + StatusBooked, + RateEncodingFactor, + MatchSideMaker, + MakerRedeemed, + TakerSwapCast, + ImmediateTiF +} from './orderutil' +import { + app, + PageElement, + ExchangeAuth, + Order, + Market +} from './registry' + +export const bondReserveMultiplier = 2 // Reserves for next bond +export const perTierBaseParcelLimit = 2 +export const parcelLimitScoreMultiplier = 3 + +export class ReputationMeter { + page: Record + host: string + + constructor (div: PageElement) { + this.page = Doc.parseTemplate(div) + Doc.cleanTemplates(this.page.penaltyMarkerTmpl) + } + + setHost (host: string) { + this.host = host + } + + update () { + const { page, host } = this + const { auth, maxScore, penaltyThreshold } = app().exchanges[host] + const { rep: { score } } = auth + + const displayTier = strongTier(auth) + + const minScore = displayTier ? displayTier * penaltyThreshold * -1 : penaltyThreshold * -1 // Just for looks + const warnPct = 25 + const scorePct = 100 - warnPct + page.scoreWarn.style.width = `${warnPct}%` + const pos = score >= 0 ? warnPct + (score / maxScore) * scorePct : warnPct - (Math.min(warnPct, score / minScore * warnPct)) + page.scorePointer.style.left = `${pos}%` + page.scoreMin.textContent = String(minScore) + page.scoreMax.textContent = String(maxScore) + const bonus = limitBonus(score, maxScore) + page.limitBonus.textContent = bonus.toFixed(1) + for (const m of Doc.applySelector(page.scoreTray, '.penalty-marker')) m.remove() + if (displayTier > 1) { + const markerPct = warnPct / displayTier + for (let i = 1; i < displayTier; i++) { + const div = page.penaltyMarkerTmpl.cloneNode(true) as PageElement + page.scoreTray.appendChild(div) + div.style.left = `${markerPct * i}%` + } + } + page.score.textContent = String(score) + page.scoreData.classList.remove('negative', 'positive') + if (score > 0) page.scoreData.classList.add('positive') + else page.scoreData.classList.add('negative') + } +} + +/* + * strongTier is the effective tier, with some respect for bond overlap, such + * that we don't count weak bonds that have already had their replacements + * confirmed. + */ +export function strongTier (auth: ExchangeAuth): number { + const { weakStrength, targetTier, effectiveTier } = auth + if (effectiveTier > targetTier) { + const diff = effectiveTier - targetTier + if (weakStrength >= diff) return targetTier + return targetTier + (diff - weakStrength) + } + return effectiveTier +} + +export function likelyTaker (ord: Order, rate: number): boolean { + if (ord.type === OrderTypeMarket || ord.tif === ImmediateTiF) return true + // Must cross the spread to be a taker (not so conservative). + if (rate === 0) return false + if (ord.sell) return ord.rate < rate + return ord.rate > rate +} + +const preparcelQuantity = (ord: Order, mkt?: Market, midGap?: number) => { + const qty = ord.qty - ord.filled + if (ord.type === OrderTypeLimit) return qty + if (ord.sell) return qty * ord.rate / RateEncodingFactor + const rate = midGap || mkt?.spot?.rate || 0 + // Caller should not call this for market orders without a mkt arg. + if (!mkt) return 0 + // This is tricky. The server will use the mid-gap rate to convert the + // order qty. We don't have a mid-gap rate, only a spot rate. + if (rate && (mkt?.spot?.bookVolume || 0) > 0) return qty * RateEncodingFactor / rate + return mkt.lotsize // server uses same fallback if book is empty +} + +export function epochWeight (ord: Order, mkt: Market, midGap?: number) { + if (ord.status !== StatusEpoch) return 0 + const qty = preparcelQuantity(ord, mkt, midGap) + const rate = midGap || mkt.spot?.rate || 0 + if (likelyTaker(ord, rate)) return qty * 2 + return qty +} + +function bookWeight (ord: Order) { + if (ord.status !== StatusBooked) return 0 + return preparcelQuantity(ord) +} + +function settlingWeight (ord: Order) { + let sum = 0 + for (const m of (ord.matches || [])) { + if (m.side === MatchSideMaker) { + if (m.status > MakerRedeemed) continue + } else if (m.status > TakerSwapCast) continue + sum += m.qty + } + return sum +} + +function parcelWeight (ord: Order, mkt: Market, midGap?: number) { + if (ord.type === OrderTypeCancel) return 0 + return epochWeight(ord, mkt, midGap) + bookWeight(ord) + settlingWeight(ord) +} + +// function roundParcels (p: number): number { +// return Math.floor(Math.round((p * 1e8)) / 1e8) +// } + +function limitBonus (score: number, maxScore: number): number { + return score > 0 ? 1 + score / maxScore * (parcelLimitScoreMultiplier - 1) : 1 +} + +export function tradingLimits (host: string): [number, number] { // [usedParcels, parcelLimit] + const { auth, maxScore, markets } = app().exchanges[host] + const { rep: { score } } = auth + const tier = strongTier(auth) + + let usedParcels = 0 + for (const mkt of Object.values(markets)) { + let mktWeight = 0 + for (const ord of (mkt.inflight || [])) mktWeight += parcelWeight(ord, mkt) + for (const ord of (mkt.orders || [])) mktWeight += parcelWeight(ord, mkt) + usedParcels += (mktWeight / (mkt.parcelsize * mkt.lotsize)) + } + const parcelLimit = perTierBaseParcelLimit * limitBonus(score, maxScore) * tier + return [usedParcels, parcelLimit] +} diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 7b192bf915..cbfdccb562 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -22,6 +22,7 @@ import { Exchange, WalletState, BondNote, + ReputationNote, CoreNote, OrderNote, Market, @@ -163,9 +164,7 @@ export default class Application { } if (process.env.NODE_ENV === 'development') { - window.user = () => { - console.log(this.user) - } + window.user = () => this.user } // use user current locale set by backend @@ -528,6 +527,7 @@ export default class Application { * is used to update the dex tier and registration status. */ handleBondNote (note: BondNote) { + if (note.auth) this.exchanges[note.dex].auth = note.auth switch (note.topic) { case 'RegUpdate': if (note.coinID !== null) { // should never be null for RegUpdate @@ -622,6 +622,11 @@ export default class Application { case 'bondpost': this.handleBondNote(note as BondNote) break + case 'reputation': { + const n = note as ReputationNote + this.exchanges[n.host].auth.rep = n.rep + break + } case 'walletstate': case 'walletconfig': { // assets can be null if failed to connect to dex server. diff --git a/client/webserver/site/src/js/charts.ts b/client/webserver/site/src/js/charts.ts index 908dba3464..63932e45d8 100644 --- a/client/webserver/site/src/js/charts.ts +++ b/client/webserver/site/src/js/charts.ts @@ -1108,7 +1108,7 @@ export class Wave extends Chart { const { region, msgRegion, canvas: { width: w, height: h }, opts: { backgroundColor: bg, message: msg }, colorShift, ctx } = this if (bg) { - if (bg === true) ctx.fillStyle = window.getComputedStyle(document.body, null).getPropertyValue('background-color') + if (bg === true) ctx.fillStyle = State.isDark() ? '#122739' : '#f0f0f0' // $dark_panel or $light_panel else ctx.fillStyle = bg ctx.fillRect(0, 0, w, h) } diff --git a/client/webserver/site/src/js/dexsettings.ts b/client/webserver/site/src/js/dexsettings.ts index 1a82359056..c524028f28 100644 --- a/client/webserver/site/src/js/dexsettings.ts +++ b/client/webserver/site/src/js/dexsettings.ts @@ -1,19 +1,31 @@ -import Doc from './doc' +import Doc, { Animation } from './doc' import BasePage from './basepage' import State from './state' import { postJSON } from './http' import * as forms from './forms' import * as intl from './locales' - +import { ReputationMeter, strongTier } from './account' import { app, PageElement, ConnectionStatus, - Exchange + Exchange, + PasswordCache, + WalletState } from './registry' +interface Animator { + animate: (() => Promise) +} + +interface BondOptionsForm { + host?: string // Required, but set by updateBondOptions + bondAssetID?: number + targetTier?: number + penaltyComps?: number +} + const animationLength = 300 -const bondOverlap = 2 // See client/core/bond.go#L28 export default class DexSettingsPage extends BasePage { body: HTMLElement @@ -24,62 +36,242 @@ export default class DexSettingsPage extends BasePage { keyup: (e: KeyboardEvent) => void dexAddrForm: forms.DEXAddressForm bondFeeBufferCache: Record + newWalletForm: forms.NewWalletForm + unlockWalletForm: forms.UnlockWalletForm + regAssetForm: forms.FeeAssetSelectionForm + walletWaitForm: forms.WalletWaitForm + confirmRegisterForm: forms.ConfirmRegistrationForm + reputationMeter: ReputationMeter + animation: Animation + pwCache: PasswordCache + renewToggle: AniToggle constructor (body: HTMLElement) { super() this.body = body - this.host = body.dataset.host ? body.dataset.host : '' + this.pwCache = { pw: '' } + const host = this.host = body.dataset.host ? body.dataset.host : '' + const xc = app().exchanges[host] const page = this.page = Doc.idDescendants(body) this.forms = Doc.applySelector(page.forms, ':scope > form') + this.confirmRegisterForm = new forms.ConfirmRegistrationForm(page.confirmRegForm, () => { + this.showSuccess(intl.prep(intl.ID_TRADING_TIER_UPDATED)) + this.renewToggle.setState(this.confirmRegisterForm.tier > 0) + }, () => { + this.runAnimation(this.regAssetForm, page.regAssetForm) + }, this.pwCache) + this.confirmRegisterForm.setExchange(xc, '') + + this.walletWaitForm = new forms.WalletWaitForm(page.walletWait, () => { + this.runAnimation(this.confirmRegisterForm, page.confirmRegForm) + }, () => { + this.runAnimation(this.regAssetForm, page.regAssetForm) + }) + this.walletWaitForm.setExchange(xc) + + this.newWalletForm = new forms.NewWalletForm( + page.newWalletForm, + assetID => this.newWalletCreated(assetID, this.confirmRegisterForm.tier), + this.pwCache, + () => this.runAnimation(this.regAssetForm, page.regAssetForm) + ) + + this.unlockWalletForm = new forms.UnlockWalletForm(page.unlockWalletForm, (assetID: number) => { + this.progressTierFormsWithWallet(assetID, app().walletMap[assetID]) + }, this.pwCache) + + this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async (assetID: number, tier: number) => { + const asset = app().assets[assetID] + const wallet = asset.wallet + if (wallet) { + const loaded = app().loading(page.regAssetForm) + const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm) + this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer) + loaded() + this.progressTierFormsWithWallet(assetID, wallet) + return + } + this.confirmRegisterForm.setAsset(assetID, tier, 0) + this.newWalletForm.setAsset(assetID) + this.showForm(page.newWalletForm) + }) + this.regAssetForm.setExchange(xc) + + this.reputationMeter = new ReputationMeter(page.repMeter) + this.reputationMeter.setHost(host) + Doc.bind(page.exportDexBtn, 'click', () => this.prepareAccountExport(page.authorizeAccountExportForm)) Doc.bind(page.disableAcctBtn, 'click', () => this.prepareAccountDisable(page.disableAccountForm)) - Doc.bind(page.bondDetailsBtn, 'click', () => this.prepareBondDetailsForm()) Doc.bind(page.updateCertBtn, 'click', () => page.certFileInput.click()) Doc.bind(page.updateHostBtn, 'click', () => this.prepareUpdateHost()) Doc.bind(page.certFileInput, 'change', () => this.onCertFileChange()) - Doc.bind(page.bondAssetSelect, 'change', () => this.updateBondAssetCosts()) - Doc.bind(page.bondTargetTier, 'input', () => this.updateBondAssetCosts()) Doc.bind(page.goBackToSettings, 'click', () => app().loadPage('settings')) + const showTierForm = () => { + this.regAssetForm.setExchange(app().exchanges[host]) // reset form + this.showForm(page.regAssetForm) + } + Doc.bind(page.changeTier, 'click', () => { showTierForm() }) + const willAutoRenew = xc.auth.targetTier > 0 + this.renewToggle = new AniToggle(page.toggleAutoRenew, page.renewErr, willAutoRenew, async (newState: boolean) => { + if (newState) showTierForm() + else return this.disableAutoRenew() + }) + Doc.bind(page.autoRenewBox, 'click', (e: MouseEvent) => { + e.stopPropagation() + page.toggleAutoRenew.click() + }) + + page.penaltyComps.textContent = String(xc.auth.penaltyComps) + const hideCompInput = () => { + Doc.hide(page.penaltyCompInput) + Doc.show(page.penaltyComps) + } + Doc.bind(page.penaltyCompBox, 'click', (e: MouseEvent) => { + e.stopPropagation() + const xc = app().exchanges[this.host] + page.penaltyCompInput.value = String(xc.auth.penaltyComps) + Doc.hide(page.penaltyComps) + Doc.show(page.penaltyCompInput) + page.penaltyCompInput.focus() + const checkClick = (e: MouseEvent) => { + if (Doc.mouseInElement(e, page.penaltyCompBox)) return + hideCompInput() + Doc.unbind(document, 'click', checkClick) + } + Doc.bind(document, 'click', checkClick) + }) + + Doc.bind(page.penaltyCompInput, 'keyup', async (e: KeyboardEvent) => { + Doc.hide(page.penaltyCompsErr) + if (e.key === 'Escape') { + hideCompInput() + return + } + if (!(e.key === 'Enter')) return + const penaltyComps = parseInt(page.penaltyCompInput.value || '') + if (isNaN(penaltyComps)) { + Doc.show(page.penaltyCompsErr) + page.penaltyCompsErr.textContent = intl.prep(intl.ID_INVALID_COMPS_VALUE) + return + } + const loaded = app().loading(page.otherBondSettings) + try { + await this.updateBondOptions({ penaltyComps }) + loaded() + page.penaltyComps.textContent = String(penaltyComps) + } catch (e) { + loaded() + Doc.show(page.penaltyCompsErr) + page.penaltyCompsErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg }) + } + hideCompInput() + }) + this.dexAddrForm = new forms.DEXAddressForm(page.dexAddrForm, async (xc: Exchange) => { window.location.assign(`/dexsettings/${xc.host}`) }, undefined, this.host) - forms.bind(page.bondDetailsForm, page.updateBondOptionsConfirm, () => this.updateBondOptions()) + // forms.bind(page.bondDetailsForm, page.updateBondOptionsConfirm, () => this.updateBondOptions()) forms.bind(page.authorizeAccountExportForm, page.authorizeExportAccountConfirm, () => this.exportAccount()) forms.bind(page.disableAccountForm, page.disableAccountConfirm, () => this.disableAccount()) - const closePopups = () => { - Doc.hide(page.forms) - } - Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => { - if (!Doc.mouseInElement(e, this.currentForm)) { closePopups() } + if (!Doc.mouseInElement(e, this.currentForm)) { this.closePopups() } }) this.keyup = (e: KeyboardEvent) => { if (e.key === 'Escape') { - closePopups() + this.closePopups() } } Doc.bind(document, 'keyup', this.keyup) page.forms.querySelectorAll('.form-closer').forEach(el => { - Doc.bind(el, 'click', () => { closePopups() }) + Doc.bind(el, 'click', () => { this.closePopups() }) }) app().registerNoteFeeder({ - conn: () => { this.setConnectionStatus() } + conn: () => { this.setConnectionStatus() }, + reputation: () => { this.updateReputation() }, + feepayment: () => { this.updateReputation() }, + bondpost: () => { this.updateReputation() } }) this.setConnectionStatus() + this.updateReputation() } unload () { Doc.unbind(document, 'keyup', this.keyup) } + async progressTierFormsWithWallet (assetID: number, wallet: WalletState) { + const { page, host, confirmRegisterForm: { fees } } = this + const asset = app().assets[assetID] + const xc = app().exchanges[host] + const bondAsset = xc.bondAssets[asset.symbol] + if (!wallet.open) { + if (State.passwordIsCached()) { + const loaded = app().loading(page.forms) + const res = await postJSON('/api/openwallet', { assetID: assetID }) + loaded() + if (!app().checkResponse(res)) { + this.regAssetForm.setError(`error unlocking wallet: ${res.msg}`) + this.runAnimation(this.regAssetForm, page.regAssetForm) + } + return + } else { + // Password not cached. Show the unlock wallet form. + this.unlockWalletForm.refresh(asset) + this.showForm(page.unlockWalletForm) + return + } + } + if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + fees) { + // If we are raising our tier, we'll show a confirmation form + this.progressTierFormWithSyncedFundedWallet(assetID) + return + } + this.walletWaitForm.setWallet(assetID, fees, this.confirmRegisterForm.tier) + this.showForm(page.walletWait) + } + + async progressTierFormWithSyncedFundedWallet (bondAssetID: number) { + const xc = app().exchanges[this.host] + const targetTier = this.confirmRegisterForm.tier + const page = this.page + const strongTier = xc.auth.liveStrength + xc.auth.pendingStrength - xc.auth.weakStrength + if (targetTier > xc.auth.targetTier && targetTier > strongTier) { + this.runAnimation(this.confirmRegisterForm, page.confirmRegForm) + return + } + // Lowering tier + const loaded = app().loading(this.body) + try { + await this.updateBondOptions({ bondAssetID, targetTier }) + loaded() + } catch (e) { + loaded() + this.regAssetForm.setError(e.msg) + return + } + // this.animateConfirmForm(page.regAssetForm) + this.showSuccess(intl.prep(intl.ID_TRADING_TIER_UPDATED)) + } + + updateReputation () { + const page = this.page + const auth = app().exchanges[this.host].auth + const { rep: { penalties }, targetTier } = auth + const displayTier = strongTier(auth) + page.targetTier.textContent = String(targetTier) + page.effectiveTier.textContent = String(displayTier) + page.penalties.textContent = String(penalties) + this.reputationMeter.update() + } + /* showForm shows a modal form with a little animation. */ async showForm (form: HTMLElement) { const page = this.page @@ -94,6 +286,28 @@ export default class DexSettingsPage extends BasePage { form.style.right = '0' } + async runAnimation (ani: Animator, form: PageElement) { + Doc.hide(this.currentForm) + await ani.animate() + this.currentForm = form + Doc.show(form) + } + + closePopups () { + Doc.hide(this.page.forms) + if (this.animation) this.animation.stop() + } + + async showSuccess (msg: string) { + this.forms.forEach(form => Doc.hide(form)) + this.currentForm = this.page.checkmarkForm + this.animation = forms.showSuccess(this.page, msg) + await this.animation.wait() + this.animation = new Animation(1500, () => { /* pass */ }, '', () => { + if (this.currentForm === this.page.checkmarkForm) this.closePopups() + }) + } + // exportAccount exports and downloads the account info. async exportAccount () { const page = this.page @@ -161,64 +375,6 @@ export default class DexSettingsPage extends BasePage { this.showForm(disableAccountForm) } - // prepareBondDetailsForm resets and prepares the Bond Details form. - async prepareBondDetailsForm () { - const page = this.page - const xc = app().user.exchanges[this.host] - // Update bond details on this form - const targetTier = xc.auth.targetTier.toString() - page.currentTargetTier.textContent = `${targetTier}` - page.currentTier.textContent = `${xc.auth.effectiveTier}` - page.bondTargetTier.setAttribute('placeholder', targetTier) - page.bondTargetTier.value = '' - this.bondFeeBufferCache = {} - Doc.empty(page.bondAssetSelect) - for (const [assetSymbol, bondAsset] of Object.entries(xc.bondAssets)) { - const option = document.createElement('option') as HTMLOptionElement - option.value = bondAsset.id.toString() - option.textContent = assetSymbol.toUpperCase() - if (bondAsset.id === xc.auth.bondAssetID) option.selected = true - page.bondAssetSelect.appendChild(option) - } - page.bondOptionsErr.textContent = '' - Doc.hide(page.bondOptionsErr) - await this.updateBondAssetCosts() - this.showForm(page.bondDetailsForm) - } - - async updateBondAssetCosts () { - const xc = app().user.exchanges[this.host] - const page = this.page - const bondAssetID = parseInt(page.bondAssetSelect.value ?? '') - Doc.hide(page.bondCostFiat.parentElement as Element) - Doc.hide(page.bondReservationAmtFiat.parentElement as Element) - const assetInfo = xc.assets[bondAssetID] - const bondAsset = xc.bondAssets[assetInfo.symbol] - - const bondCost = bondAsset.amount - const ui = assetInfo.unitInfo - const assetID = bondAsset.id - Doc.applySelector(page.bondDetailsForm, '.bondAssetSym').forEach((el) => { el.textContent = assetInfo.symbol.toLocaleUpperCase() }) - page.bondCost.textContent = Doc.formatFullPrecision(bondCost, ui) - const xcRate = app().fiatRatesMap[assetID] - Doc.showFiatValue(page.bondCostFiat, bondCost, xcRate, ui) - - let feeBuffer = this.bondFeeBufferCache[assetInfo.symbol] - if (!feeBuffer) { - feeBuffer = await this.getBondsFeeBuffer(assetID, page.bondDetailsForm) - if (feeBuffer > 0) this.bondFeeBufferCache[assetInfo.symbol] = feeBuffer - } - if (feeBuffer === 0) { - page.bondReservationAmt.textContent = intl.prep(intl.ID_UNAVAILABLE) - return - } - const targetTier = parseInt(page.bondTargetTier.value ?? '') - let reservation = 0 - if (targetTier > 0) reservation = bondCost * targetTier * bondOverlap + feeBuffer - page.bondReservationAmt.textContent = Doc.formatFullPrecision(reservation, ui) - Doc.showFiatValue(page.bondReservationAmtFiat, reservation, xcRate, ui) - } - // Retrieve an estimate for the tx fee needed to create new bond reserves. async getBondsFeeBuffer (assetID: number, form: HTMLElement) { const loaded = app().loading(form) @@ -285,44 +441,87 @@ export default class DexSettingsPage extends BasePage { } } + async disableAutoRenew () { + const loaded = app().loading(this.page.otherBondSettings) + try { + this.updateBondOptions({ targetTier: 0 }) + loaded() + } catch (e) { + loaded() + throw e + } + } + /* * updateBondOptions is called when the form to update bond options is * submitted. */ - async updateBondOptions () { + async updateBondOptions (conf: BondOptionsForm): Promise { + conf.host = this.host + await postJSON('/api/updatebondoptions', conf) + const targetTier = conf.targetTier ?? app().exchanges[this.host].auth.targetTier + this.renewToggle.setState(targetTier > 0) + } + + async newWalletCreated (assetID: number, tier: number) { + this.regAssetForm.refresh() + const user = await app().fetchUser() + if (!user) return const page = this.page - const targetTier = parseInt(page.bondTargetTier.value ?? '') - const bondAssetID = parseInt(page.bondAssetSelect.value ?? '') + const asset = user.assets[assetID] + const wallet = asset.wallet + const xc = app().exchanges[this.host] + const bondAmt = xc.bondAssets[asset.symbol].amount - const bondOptions: Record = { - host: this.host, - targetTier: targetTier, - bondAssetID: bondAssetID - } + const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.newWalletForm) + this.confirmRegisterForm.setFees(assetID, bondsFeeBuffer) - const assetInfo = app().assets[bondAssetID] - if (assetInfo) { - const feeBuffer = this.bondFeeBufferCache[assetInfo.symbol] - if (feeBuffer > 0) bondOptions.feeBuffer = feeBuffer + if (wallet.synced && wallet.balance.available >= 2 * bondAmt + bondsFeeBuffer) { + this.progressTierFormWithSyncedFundedWallet(assetID) + return } - const loaded = app().loading(this.body) - const res = await postJSON('/api/updatebondoptions', bondOptions) - loaded() - if (!app().checkResponse(res)) { - page.bondOptionsErr.textContent = res.msg - Doc.show(page.bondOptionsErr) - } else { - Doc.hide(page.bondOptionsErr) - Doc.show(page.bondOptionsMsg) - setTimeout(() => { - Doc.hide(page.bondOptionsMsg) - }, 5000) - // update the in-memory values. - const xc = app().user.exchanges[this.host] - xc.auth.bondAssetID = bondAssetID - xc.auth.targetTier = targetTier - page.currentTargetTier.textContent = `${targetTier}` - } + this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier) + await this.showForm(page.walletWait) + } +} + +/* + * AniToggle is a small toggle switch, defined in HTML with the element + *
. The animations are defined in the anitoggle + * CSS class. AniToggle triggers the callback on click events, but does not + * update toggle appearance, so the caller must call the setState method from + * the callback or elsewhere if the newState + * is accepted. + */ +class AniToggle { + toggle: PageElement + toggling: boolean + + constructor (toggle: PageElement, errorEl: PageElement, initialState: boolean, callback: (newState: boolean) => Promise) { + this.toggle = toggle + if (toggle.children.length === 0) toggle.appendChild(document.createElement('div')) + + Doc.bind(toggle, 'click', async (e: MouseEvent) => { + e.stopPropagation() + Doc.hide(errorEl) + const newState = !toggle.classList.contains('on') + this.toggling = true + try { + await callback(newState) + } catch (e) { + this.toggling = false + Doc.show(errorEl) + errorEl.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg }) + return + } + this.toggling = false + }) + this.setState(initialState) + } + + setState (state: boolean) { + if (state) this.toggle.classList.add('on') + else this.toggle.classList.remove('on') } } diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 277a1bee2d..4f26267370 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -59,11 +59,6 @@ const fourSigFigs = new Intl.NumberFormat((navigator.languages as string[]), { maximumSignificantDigits: 4 }) -const oneFractionalDigit = new Intl.NumberFormat((navigator.languages as string[]), { - minimumFractionDigits: 1, - maximumFractionDigits: 1 -}) - /* A cache for formatters used for Doc.formatCoinValue. */ const decimalFormatters: Record = {} @@ -302,7 +297,7 @@ export default class Doc { } static formatFourSigFigs (n: number): string { - return formatSigFigsWithFormatters(oneFractionalDigit, fourSigFigs, n) + return formatSigFigsWithFormatters(intFormatter, fourSigFigs, n) } /* diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index d2315379de..ca5c2ef7a9 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -1,9 +1,15 @@ -import Doc from './doc' +import Doc, { Animation } from './doc' import { postJSON } from './http' import State from './state' import * as intl from './locales' import * as OrderUtil from './orderutil' import { Wave } from './charts' +import { + bondReserveMultiplier, + perTierBaseParcelLimit, + parcelLimitScoreMultiplier, + strongTier +} from './account' import { app, PasswordCache, @@ -753,6 +759,8 @@ export class ConfirmRegistrationForm { xc: Exchange certFile: string bondAssetID: number + tier: number + fees: number pwCache: PasswordCache constructor (form: HTMLElement, success: () => void, goBack: () => void, pwCache: PasswordCache) { @@ -763,17 +771,6 @@ export class ConfirmRegistrationForm { this.pwCache = pwCache Doc.bind(this.page.goBack, 'click', () => goBack()) - // bondStrengthField is presently hidden since there is no scaling of user - // limits yet, and there needs to be considerable explanation of why - // anything other than 1 would be used. Unhide bondStrengthInput to show it - // when we are ready. (TODO) - Doc.bind(this.page.bondStrengthField, 'input', () => { - const asset = app().assets[this.bondAssetID] - if (!asset) return - const ui = asset.unitInfo - const bondAsset = this.xc.bondAssets[asset.symbol] - this.page.bondAmt.textContent = Doc.formatCoinValue(this.totalBondAmount(bondAsset.amount), ui) - }) bind(form, this.page.submit, () => this.submitForm()) } @@ -786,20 +783,33 @@ export class ConfirmRegistrationForm { page.host.textContent = xc.host } - setAsset (assetID: number) { + setAsset (assetID: number, tier: number, fees: number) { const asset = app().assets[assetID] - const ui = asset.unitInfo + const { conversionFactor, unit } = asset.unitInfo.conventional this.bondAssetID = asset.id + this.tier = tier + this.fees = fees const page = this.page - const bondAsset = this.xc.bondAssets[asset.symbol] - page.bondAmt.textContent = Doc.formatCoinValue(this.totalBondAmount(bondAsset.amount), ui) - page.bondUnit.textContent = ui.conventional.unit.toUpperCase() + const xc = this.xc + const bondAsset = xc.bondAssets[asset.symbol] + const bondLock = bondAsset.amount * tier * bondReserveMultiplier + const bondLockConventional = bondLock / conversionFactor + page.tradingTier.textContent = String(tier) page.logo.src = Doc.logoPath(asset.symbol) + page.bondLock.textContent = Doc.formatFourSigFigs(bondLockConventional) + page.bondUnit.textContent = unit + const r = app().fiatRatesMap[assetID] + Doc.show(page.bondLockUSDBox) + if (r) page.bondLockUSD.textContent = Doc.formatFourSigFigs(bondLockConventional * r) + else Doc.hide(page.bondLockUSDBox) + if (fees) page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor) + page.reservesUnit.textContent = unit } - totalBondAmount (singleBondAmount: number): number { - const bondStrength = +(this.page.bondStrengthField.value ?? 1) - return bondStrength * singleBondAmount + setFees (assetID: number, fees: number) { + this.fees = fees + const conversionFactor = app().assets[assetID].unitInfo.conventional.conversionFactor + this.page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor) } /* Form expands into its space quickly from the lower-right as it fades in. */ @@ -830,20 +840,34 @@ export class ConfirmRegistrationForm { return } Doc.hide(page.regErr) - const bondAsset = this.xc.bondAssets[asset.wallet.symbol] + const xc = this.xc + const bondAsset = xc.bondAssets[asset.wallet.symbol] const cert = await this.certFile - const dexAddr = this.xc.host + const dexAddr = xc.host const pw = page.appPass.value || (this.pwCache ? this.pwCache.pw : '') - const postBondForm = { - addr: dexAddr, - cert: cert, - pass: pw, - bond: this.totalBondAmount(bondAsset.amount), - asset: bondAsset.id + let form: any + let url: string + + if (xc.viewOnly || !app().exchanges[xc.host]) { + form = { + addr: dexAddr, + cert: cert, + pass: pw, + bond: bondAsset.amount * this.tier, + asset: bondAsset.id + } + url = '/api/postbond' + } else { + form = { + host: dexAddr, + targetTier: this.tier, + bondAssetID: this.bondAssetID + } + url = '/api/updatebondoptions' } page.appPass.value = '' const loaded = app().loading(this.form) - const res = await postJSON('/api/postbond', postBondForm) + const res = await postJSON(url, form) loaded() if (!app().checkResponse(res)) { page.regErr.textContent = res.msg @@ -854,21 +878,68 @@ export class ConfirmRegistrationForm { } } +interface RegAssetTemplate { + tmpl: Record + asset: SupportedAsset + setTier: ((tier: number) => void) +} + /* * FeeAssetSelectionForm should be used with the "regAssetForm" template. */ export class FeeAssetSelectionForm { form: HTMLElement - success: (assetID: number) => void + success: (assetID: number, tier: number) => Promise xc: Exchange page: Record - assetTmpls: Record> + assetTmpls: Record - constructor (form: HTMLElement, success: (assetID: number) => Promise) { + constructor (form: HTMLElement, success: (assetID: number, tier: number) => Promise) { this.form = form this.success = success - this.page = Doc.parseTemplate(form) - Doc.cleanTemplates(this.page.marketTmpl, this.page.assetTmpl) + const page = this.page = Doc.parseTemplate(form) + Doc.cleanTemplates(page.assetTmpl, page.marketTmpl) + + Doc.bind(page.regAssetTier, 'input', () => { + this.clearError() + const raw = page.regAssetTier.value ?? '' + const tier = parseInt(raw) + if (isNaN(tier)) { + if (raw) this.setError(intl.prep(intl.ID_INVALID_TIER_VALUE)) + return + } + for (const a of Object.values(this.assetTmpls)) a.setTier(tier) + }) + + Doc.bind(page.regAssetTier, 'keyup', (e: KeyboardEvent) => { + if (!(e.key === 'Enter')) return + const { auth: { targetTier, bondAssetID }, viewOnly } = this.xc + if (viewOnly || targetTier === 0) { // They need to select an asset. + Doc.hide(page.assetSelected) // Probably already showing, but do it anyway. + Doc.show(page.assetSelection) + return + } + this.submit(bondAssetID) + }) + + Doc.bind(page.chooseDifferentAsset, 'click', () => { + Doc.hide(page.assetSelected) + Doc.show(page.assetSelection) + }) + + Doc.bind(page.whatsABond, 'click', () => { + Doc.hide(page.mainBondingForm) + Doc.show(page.whatsABondPanel) + }) + + const hideWhatsABond = () => { + Doc.show(page.mainBondingForm) + Doc.hide(page.whatsABondPanel) + } + + Doc.bind(page.bondGotIt, 'click', () => { hideWhatsABond() }) + + Doc.bind(page.whatsABondBack, 'click', () => { hideWhatsABond() }) app().registerNoteFeeder({ createwallet: (note: WalletCreationNote) => { @@ -877,11 +948,21 @@ export class FeeAssetSelectionForm { }) } + setError (errMsg: string) { + this.page.regAssetErr.textContent = errMsg + Doc.show(this.page.regAssetErr) + } + + clearError () { + Doc.hide(this.page.regAssetErr) + } + setExchange (xc: Exchange) { this.xc = xc this.assetTmpls = {} const page = this.page - Doc.empty(page.assets, page.allMarkets) + Doc.empty(page.assets, page.otherAssets, page.allMarkets) + this.clearError() const cFactor = (ui: UnitInfo) => ui.conventional.conversionFactor @@ -889,10 +970,10 @@ export class FeeAssetSelectionForm { const n = page.marketTmpl.cloneNode(true) as HTMLElement const marketTmpl = Doc.parseTemplate(n) - const baseAsset = app().assets[mkt.baseid] - const quoteAsset = app().assets[mkt.quoteid] + const { symbol: baseSymbol, unitInfo: bui } = xc.assets[mkt.baseid] + const { symbol: quoteSymbol, unitInfo: qui } = xc.assets[mkt.quoteid] - if (cFactor(baseAsset.unitInfo) === 0 || cFactor(quoteAsset.unitInfo) === 0) return null + if (cFactor(bui) === 0 || cFactor(qui) === 0) return null if (typeof excludeIcon !== 'undefined') { const excludeBase = excludeIcon === mkt.baseid @@ -900,55 +981,131 @@ export class FeeAssetSelectionForm { marketTmpl.logo.src = Doc.logoPath(otherSymbol) } else { const otherLogo = marketTmpl.logo.cloneNode(true) as PageElement - marketTmpl.logo.src = Doc.logoPath(baseAsset.symbol) - otherLogo.src = Doc.logoPath(quoteAsset.symbol) + marketTmpl.logo.src = Doc.logoPath(baseSymbol) + otherLogo.src = Doc.logoPath(quoteSymbol) const parent = marketTmpl.logo.parentNode if (parent) parent.insertBefore(otherLogo, marketTmpl.logo.nextSibling) } - marketTmpl.baseName.replaceWith(Doc.symbolize(baseAsset)) - marketTmpl.quoteName.replaceWith(Doc.symbolize(quoteAsset)) + marketTmpl.baseName.textContent = bui.conventional.unit + marketTmpl.quoteName.textContent = qui.conventional.unit - marketTmpl.lotSize.textContent = Doc.formatCoinValue(mkt.lotsize, baseAsset.unitInfo) - marketTmpl.lotSizeSymbol.replaceWith(Doc.symbolize(baseAsset)) + marketTmpl.lotSize.textContent = Doc.formatCoinValue(mkt.lotsize, bui) + marketTmpl.lotSizeSymbol.replaceWith(bui.conventional.unit) if (mkt.spot) { Doc.show(marketTmpl.quoteLotSize) - const r = cFactor(quoteAsset.unitInfo) / cFactor(baseAsset.unitInfo) + const r = cFactor(qui) / cFactor(bui) const quoteLot = mkt.lotsize * mkt.spot.rate / OrderUtil.RateEncodingFactor * r - const s = Doc.formatCoinValue(quoteLot, quoteAsset.unitInfo) - marketTmpl.quoteLotSize.textContent = `(~${s} ${quoteAsset.symbol})` + const s = Doc.formatCoinValue(quoteLot, qui) + marketTmpl.quoteLotSize.textContent = `(~${s} ${qui.conventional.unit})` } return n } - for (const [symbol, bondAsset] of Object.entries(xc.bondAssets)) { - const asset = app().assets[bondAsset.id] - if (!asset) continue + const addAssetRow = (table: PageElement, assetID: number, bondAsset?: BondAsset) => { + const asset = app().assets[assetID] + if (!asset) return const unitInfo = asset.unitInfo - const assetNode = page.assetTmpl.cloneNode(true) as HTMLElement - Doc.bind(assetNode, 'click', () => { this.success(bondAsset.id) }) - const assetTmpl = this.assetTmpls[bondAsset.id] = Doc.parseTemplate(assetNode) - page.assets.appendChild(assetNode) - assetTmpl.logo.src = Doc.logoPath(symbol) - const fee = Doc.formatCoinValue(bondAsset.amount, unitInfo) - assetTmpl.feeAmt.textContent = String(fee) - assetTmpl.feeSymbol.replaceWith(Doc.symbolize(asset)) - assetTmpl.confs.textContent = String(bondAsset.confs) - setReadyMessage(assetTmpl.ready, asset) - - let count = 0 - for (const mkt of Object.values(xc.markets)) { - if (mkt.baseid !== bondAsset.id && mkt.quoteid !== bondAsset.id) continue - const node = marketNode(mkt, bondAsset.id) - if (!node) continue - count++ - assetTmpl.markets.appendChild(node) + const tr = page.assetTmpl.cloneNode(true) as HTMLElement + table.appendChild(tr) + const tmpl = Doc.parseTemplate(tr) + + tmpl.logo.src = Doc.logoPath(asset.symbol) + + tmpl.tradeLimitSymbol.textContent = unitInfo.conventional.unit + tmpl.name.textContent = asset.name + // assetTmpl.confs.textContent = String(bondAsset.confs) + const setTier = (tier: number) => { + let low = 0 + let high = 0 + const cFactor = app().unitInfo(asset.id, xc).conventional.conversionFactor + for (const { baseid: baseID, quoteid: quoteID, parcelsize: parcelSize, lotsize: lotSize, spot } of Object.values(xc.markets)) { + const conventionalLotSize = lotSize / cFactor + let resolvedLotSize = 0 + if (baseID === asset.id) { + resolvedLotSize = conventionalLotSize + } else if (quoteID === asset.id) { + const baseRate = app().fiatRatesMap[baseID] + const quoteRate = app().fiatRatesMap[quoteID] + if (baseRate && quoteRate) { + resolvedLotSize = conventionalLotSize * baseRate / quoteRate + } else if (spot) { + const bui = xc.assets[baseID].unitInfo + const qui = xc.assets[quoteID].unitInfo + const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor + const conventionalRate = spot.rate / rateConversionFactor + resolvedLotSize = conventionalLotSize * conventionalRate + } + } + if (resolvedLotSize) { + const startingLimit = resolvedLotSize * parcelSize * perTierBaseParcelLimit * tier + const privilegedLimit = resolvedLotSize * parcelSize * perTierBaseParcelLimit * parcelLimitScoreMultiplier * tier + if (low === 0 || startingLimit < low) low = startingLimit + if (privilegedLimit > high) high = privilegedLimit + } + } + + const r = app().fiatRatesMap[assetID] + + if (low && high) { + tmpl.tradeLimitLow.textContent = Doc.formatFourSigFigs(low) + tmpl.tradeLimitHigh.textContent = Doc.formatFourSigFigs(high) + if (r) { + tmpl.fiatTradeLimitLow.textContent = Doc.formatFourSigFigs(low * r) + tmpl.fiatTradeLimitHigh.textContent = Doc.formatFourSigFigs(high * r) + } + } + + if (!bondAsset) { + Doc.hide(tmpl.bondData, tmpl.ready) + tr.classList.remove('hoverbg', 'pointer') + return + } + setReadyMessage(tmpl.ready, asset) + const bondLock = bondAsset.amount * bondReserveMultiplier * tier + const fee = Doc.formatCoinValue(bondLock, unitInfo) + tmpl.feeAmt.textContent = String(fee) + if (r) tmpl.fiatBondAmount.textContent = Doc.formatFiatConversion(bondLock, r, asset.unitInfo) + } + + if (bondAsset) { + Doc.bind(tr, 'click', () => { this.submit(assetID) }) + tmpl.feeSymbol.textContent = unitInfo.conventional.unit } - if (count < 3) Doc.hide(assetTmpl.fader) + + setTier(strongTier(xc.auth) || 1) + this.assetTmpls[asset.symbol] = { tmpl, asset, setTier } + } + + const nonBondAssets: number[] = [] + for (const { symbol, id: assetID } of Object.values(xc.assets)) { + if (!app().assets[assetID]) continue + const bondAsset = xc.bondAssets[symbol] + if (bondAsset) { + addAssetRow(page.assets, assetID, bondAsset) + continue + } + nonBondAssets.push(assetID) + } + + for (const assetID of nonBondAssets) { + addAssetRow(page.otherAssets, assetID) } page.host.textContent = xc.host + page.regAssetTier.value = xc.auth.targetTier ? String(xc.auth.targetTier) : '1' + + Doc.show(page.assetSelection) + Doc.hide(page.assetSelected) + if (!xc.viewOnly && xc.auth.targetTier > 0) { + const currentBondAsset = app().assets[xc.auth.bondAssetID] + page.currentAssetLogo.src = Doc.logoPath(currentBondAsset.symbol) + page.currentAssetName.textContent = currentBondAsset.name + Doc.hide(page.assetSelection) + Doc.show(page.assetSelected) + } + for (const mkt of Object.values(xc.markets)) { const node = marketNode(mkt) if (!node) continue @@ -961,25 +1118,34 @@ export class FeeAssetSelectionForm { * completes successfully. */ walletCreated (assetID: number) { - const tmpl = this.assetTmpls[assetID] + const a = this.assetTmpls[assetID] const asset = app().assets[assetID] - setReadyMessage(tmpl.ready, asset) + setReadyMessage(a.tmpl.ready, asset) } refresh () { this.setExchange(this.xc) } + submit (assetID: number) { + this.clearError() + const raw = this.page.regAssetTier.value ?? '' + const tier = parseInt(raw) + if (isNaN(tier)) { + if (raw) this.setError(intl.prep(intl.ID_INVALID_TIER_VALUE)) + return + } + this.success(assetID, tier) + } + /* * Animation to make the elements sort of expand into their space from the * bottom as they fade in. */ async animate () { const { page, form } = this - const how = page.how const extraMargin = 75 const extraTop = 50 - const fontSize = 24 const regAssetElements = Array.from(page.assets.children) as PageElement[] regAssetElements.push(page.allmkts) form.style.opacity = '0' @@ -992,7 +1158,6 @@ export class FeeAssetSelectionForm { } form.style.opacity = Math.pow(prog, 4).toFixed(1) form.style.top = `${(1 - prog) * extraTop}px` - how.style.fontSize = `${fontSize * prog}px` }, 'easeOut') } } @@ -1054,17 +1219,18 @@ export class WalletWaitForm { } /* setWallet must be called before showing the WalletWaitForm. */ - setWallet (wallet: WalletState, bondFeeBuffer: number) { - this.assetID = wallet.assetID + setWallet (assetID: number, bondFeeBuffer: number, tier: number) { + this.assetID = assetID this.progressCache = [] this.progressed = false this.funded = false this.bondFeeBuffer = bondFeeBuffer // in case we're a token, parent's balance must cover this.parentAssetSynced = false const page = this.page - const asset = app().assets[wallet.assetID] - this.parentID = asset.token?.parentID - const bondAsset = this.bondAsset = this.xc.bondAssets[asset.symbol] + const asset = app().assets[assetID] + const { symbol, unitInfo: ui, wallet: { balance: bal, address, synced, syncProgress }, token } = asset + this.parentID = token?.parentID + const bondAsset = this.bondAsset = this.xc.bondAssets[symbol] const symbolize = (el: PageElement, asset: SupportedAsset) => { Doc.empty(el) @@ -1072,26 +1238,30 @@ export class WalletWaitForm { } for (const span of Doc.applySelector(this.form, '.unit')) symbolize(span, asset) - page.logo.src = Doc.logoPath(asset.symbol) - page.depoAddr.textContent = wallet.address - page.fee.textContent = Doc.formatCoinValue(bondAsset.amount, asset.unitInfo) + page.logo.src = Doc.logoPath(symbol) + page.depoAddr.textContent = address - Doc.hide(page.syncUncheck, page.syncCheck, page.balUncheck, page.balCheck, page.syncRemainBox) + Doc.hide(page.syncUncheck, page.syncCheck, page.balUncheck, page.balCheck, page.syncRemainBox, page.bondCostBreakdown) Doc.show(page.balanceBox) + let bondLock = 2 * bondAsset.amount * tier if (bondFeeBuffer > 0) { - // overlap * increment + buffer - page.totalForBond.textContent = Doc.formatCoinValue(2 * bondAsset.amount + bondFeeBuffer, asset.unitInfo) + Doc.show(page.bondCostBreakdown) + page.bondLockNoFees.textContent = Doc.formatCoinValue(bondLock, ui) + page.bondLockFees.textContent = Doc.formatCoinValue(bondFeeBuffer, ui) + bondLock += bondFeeBuffer + const need = Math.max(bondLock - bal.available + bal.reservesDeficit, 0) + page.totalForBond.textContent = Doc.formatCoinValue(need, ui) Doc.hide(page.sendEnough) // generic msg when no fee info available when Doc.hide(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox) // for tokens Doc.hide(page.sendEnoughWithEst) // non-tokens - if (asset.token) { + if (token) { Doc.show(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox) - const parentAsset = app().assets[asset.token.parentID] + const parentAsset = app().assets[token.parentID] page.txFee.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo) page.parentFees.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo) - page.tokenFees.textContent = Doc.formatCoinValue(bondAsset.amount, asset.unitInfo) + page.tokenFees.textContent = Doc.formatCoinValue(need, ui) symbolize(page.txFeeUnit, parentAsset) symbolize(page.parentUnit, parentAsset) symbolize(page.parentBalUnit, parentAsset) @@ -1099,19 +1269,20 @@ export class WalletWaitForm { } else { Doc.show(page.sendEnoughWithEst) } + page.fee.textContent = Doc.formatCoinValue(bondLock, ui) } else { // show some generic message with no amounts, this shouldn't happen... show wallet error? Doc.show(page.sendEnough) } - Doc.show(wallet.synced ? page.syncCheck : wallet.syncProgress >= 1 ? page.syncSpinner : page.syncUncheck) - Doc.show(wallet.balance.available >= 2 * bondAsset.amount + bondFeeBuffer ? page.balCheck : page.balUncheck) + Doc.show(synced ? page.syncCheck : syncProgress >= 1 ? page.syncSpinner : page.syncUncheck) + Doc.show(bal.available >= 2 * bondAsset.amount + bondFeeBuffer ? page.balCheck : page.balUncheck) - page.progress.textContent = (wallet.syncProgress * 100).toFixed(1) + page.progress.textContent = (syncProgress * 100).toFixed(1) - if (wallet.synced) { + if (synced) { this.progressed = true } - this.reportBalance(wallet.assetID) + this.reportBalance(assetID) } /* @@ -1607,8 +1778,8 @@ export class DEXAddressForm { } return } + await app().fetchUser() if (!this.dexToUpdate && (skipRegistration || res.paid || Object.keys(res.xc.auth.pendingBonds).length > 0)) { - await app().fetchUser() await app().loadPage('markets') return } @@ -1933,6 +2104,22 @@ export async function slideSwap (form1: HTMLElement, form2: HTMLElement) { form2.style.right = '0' } +export function showSuccess (page: Record, msg: string) { + page.successMessage.textContent = msg + Doc.show(page.forms, page.checkmarkForm) + page.checkmarkForm.style.right = '0' + page.checkmark.style.fontSize = '0px' + + const [startR, startG, startB] = State.isDark() ? [223, 226, 225] : [51, 51, 51] + const [endR, endG, endB] = [16, 163, 16] + const [diffR, diffG, diffB] = [endR - startR, endG - startG, endB - startB] + + return new Animation(1200, (prog: number) => { + page.checkmark.style.fontSize = `${prog * 80}px` + page.checkmark.style.color = `rgb(${startR + prog * diffR}, ${startG + prog * diffG}, ${startB + prog * diffB})` + }, 'easeOutElastic') +} + /* * bind binds the click and submit events and prevents page reloading on * submission. diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index e6985f17b7..386dbedb8c 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -154,6 +154,9 @@ export const ID_ORDER_BUTTON_QTY_ERROR = 'ID_ORDER_BUTTON_QTY_ERROR' export const ID_ORDER_BUTTON_QTY_RATE_ERROR = 'ID_ORDER_BUTTON_QTY_RATE_ERROR' export const ID_CREATE_ASSET_WALLET_MSG = 'CREATE_ASSET_WALLET_MSG' export const ID_NO_WALLET_MSG = 'ID_NO_WALLET_MSG' +export const ID_TRADING_TIER_UPDATED = 'TRADING_TIER_UPDATED' +export const ID_INVALID_TIER_VALUE = 'INVALID_TIER_VALUE' +export const ID_INVALID_COMPS_VALUE = 'INVALID_COMPS_VALUE' export const enUS: Locale = { [ID_NO_PASS_ERROR_MSG]: 'password cannot be empty', @@ -309,7 +312,11 @@ export const enUS: Locale = { [ID_BROWSER_NTFN_BONDS]: 'Bonds', [ID_BROWSER_NTFN_CONNECTIONS]: 'Server connections', [ID_CREATE_ASSET_WALLET_MSG]: 'Create a {{ asset }} wallet to trade', - [ID_NO_WALLET_MSG]: 'Create {{ asset1 }} and {{ asset2 }} wallet to trade' + [ID_NO_WALLET_MSG]: 'Create {{ asset1 }} and {{ asset2 }} wallet to trade', + [ID_TRADING_TIER_UPDATED]: 'Trading Tier Updated', + [ID_INVALID_TIER_VALUE]: 'Invalid tier value', + [ID_INVALID_COMPS_VALUE]: 'Invalid comps value', + [ID_API_ERROR]: 'api error: {{ msg }}' } export const ptBR: Locale = { diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index c809464f52..4149c93d5e 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -2,6 +2,7 @@ import Doc, { WalletIcons } from './doc' import State from './state' import BasePage from './basepage' import OrderBook from './orderbook' +import { ReputationMeter, tradingLimits, strongTier } from './account' import { CandleChart, DepthChart, @@ -176,6 +177,7 @@ export default class MarketsPage extends BasePage { unlockForm: UnlockWalletForm newWalletForm: NewWalletForm depositAddrForm: DepositAddress + reputationMeter: ReputationMeter keyup: (e: KeyboardEvent) => void secondTicker: number candlesLoading: LoadTracker | null @@ -265,6 +267,8 @@ export default class MarketsPage extends BasePage { this.depositAddrForm = new DepositAddress(page.deposit) } + this.reputationMeter = new ReputationMeter(page.reputationMeter) + // Bind toggle wallet status form. bindForm(page.toggleWalletStatusConfirm, page.toggleWalletStatusSubmit, async () => { this.toggleWalletStatus() }) @@ -504,7 +508,9 @@ export default class MarketsPage extends BasePage { balance: (note: BalanceNote) => { this.handleBalanceNote(note) }, bondpost: (note: BondNote) => { this.handleBondUpdate(note) }, spots: (note: SpotPriceNote) => { this.handlePriceUpdate(note) }, - walletstate: (note: WalletStateNote) => { this.handleWalletState(note) } + walletstate: (note: WalletStateNote) => { this.handleWalletState(note) }, + reputation: () => { this.updateReputation() }, + feepayment: () => { this.updateReputation() } }) this.loadingAnimations = {} @@ -754,6 +760,11 @@ export default class MarketsPage extends BasePage { Doc.setVis(await showOrderForm(), page.orderForm, page.orderTypeBttns) + if (this.market) { + const { auth: { effectiveTier, pendingStrength } } = this.market.dex + Doc.setVis(effectiveTier > 0 || pendingStrength > 0, page.tradingLimits, page.reputationMeter) + } + if (app().user.experimental && this.mmRunning === undefined) { const marketMakingStatus = await app().getMarketMakingStatus() this.mmRunning = marketMakingStatus.running @@ -1197,6 +1208,8 @@ export default class MarketsPage extends BasePage { this.setCandleDurBttns() this.previewQuoteAmt(false) this.updateTitle() + this.reputationMeter.setHost(dex.host) + this.updateReputation() this.loadUserOrders() } @@ -1538,7 +1551,7 @@ export default class MarketsPage extends BasePage { /* * midGapConventional is the same as midGap, but returns the mid-gap rate as * the conventional ratio. This is used to convert from a conventional - * quantity from base to quote or vice-versa. + * quantity from base to quote or vice-versa, or for display purposes. */ midGapConventional () { const gap = this.midGap() @@ -1551,7 +1564,9 @@ export default class MarketsPage extends BasePage { * midGap returns the value in the middle of the best buy and best sell. If * either one of the buy or sell sides are empty, midGap returns the best rate * from the other side. If both sides are empty, midGap returns the value - * null. The rate returned is the atomic ratio. + * null. The rate returned is the atomic ratio, used for conversion. For a + * conventional rate for display or to convert conventional units, use + * midGapConventional */ midGap () { const book = this.book @@ -2485,16 +2500,69 @@ export default class MarketsPage extends BasePage { // Update local copy of Exchange. this.market.dex = app().exchanges[dexAddr] this.setRegistrationStatusVisibility() + this.updateReputation() + } + + updateReputation () { + const { page, market: { dex: { host }, cfg: mkt, baseCfg: { unitInfo: bui }, quoteCfg: { unitInfo: qui } } } = this + const { auth } = app().exchanges[host] + + page.parcelSizeLots.textContent = String(mkt.parcelsize) + page.marketLimitBase.textContent = Doc.formatFourSigFigs(mkt.parcelsize * mkt.lotsize / bui.conventional.conversionFactor) + page.marketLimitBaseUnit.textContent = bui.conventional.unit + page.marketLimitQuoteUnit.textContent = qui.conventional.unit + const conversionRate = this.anyRate()[1] + if (conversionRate) { + const quoteLot = mkt.lotsize * conversionRate + page.marketLimitQuote.textContent = Doc.formatFourSigFigs(quoteLot / qui.conventional.conversionFactor) + } else page.marketLimitQuote.textContent = '-' + + const tier = strongTier(auth) + page.tradingTier.textContent = String(tier) + const [usedParcels, parcelLimit] = tradingLimits(host) + page.tradingLimit.textContent = String(parcelLimit * mkt.parcelsize) + page.limitUsage.textContent = parcelLimit > 0 ? (usedParcels / parcelLimit * 100).toFixed(1) : '0' + + page.orderLimitRemain.textContent = ((parcelLimit - usedParcels) * mkt.parcelsize).toFixed(1) + page.orderTradingTier.textContent = String(tier) + + this.reputationMeter.update() + } + + /* + * anyRate finds the best rate from any of, in order of priority, the order + * book, the server's reported spot rate, or the fiat exchange rates. A + * 3-tuple of message-rate encoding, a conversion rate, and a conventional + * rate is generated. + */ + anyRate (): [number, number, number] { + const { cfg: { spot }, base: { id: baseID }, quote: { id: quoteID }, rateConversionFactor } = this.market + const midGap = this.midGap() + if (midGap) return [midGap * OrderUtil.RateEncodingFactor, midGap, this.midGapConventional() || 0] + if (spot && spot.rate) return [spot.rate, spot.rate / OrderUtil.RateEncodingFactor, spot.rate / rateConversionFactor] + const [baseUSD, quoteUSD] = [app().fiatRatesMap[baseID], app().fiatRatesMap[quoteID]] + if (baseUSD && quoteUSD) { + const conventionalRate = baseUSD / quoteUSD + const msgRate = conventionalRate * rateConversionFactor + const conversionRate = msgRate / OrderUtil.RateEncodingFactor + return [msgRate, conversionRate, conventionalRate] + } + return [0, 0, 0] } handleMatchNote (note: MatchNote) { const mord = this.metaOrders[note.orderID] + const match = note.match if (!mord) return this.refreshActiveOrders() - else if (mord.ord.type === OrderUtil.Market && note.match.status === OrderUtil.NewlyMatched) { // Update the average market rate display. + else if (mord.ord.type === OrderUtil.Market && match.status === OrderUtil.NewlyMatched) { // Update the average market rate display. // Fetch and use the updated order. const ord = app().order(note.orderID) if (ord) mord.details.rate.textContent = mord.header.rate.textContent = this.marketOrderRateString(ord, this.market) } + if ( + (match.side === OrderUtil.MatchSideMaker && match.status === OrderUtil.MakerRedeemed) || + (match.side === OrderUtil.MatchSideTaker && match.status === OrderUtil.MatchComplete) + ) this.updateReputation() if (app().canAccelerateOrder(mord.ord)) Doc.show(mord.details.accelerateBttn) else Doc.hide(mord.details.accelerateBttn) } @@ -2504,8 +2572,8 @@ export default class MarketsPage extends BasePage { * used to update a user's order's status. */ handleOrderNote (note: OrderNote) { - const order = note.order - const mord = this.metaOrders[order.id] + const ord = note.order + const mord = this.metaOrders[ord.id] // - If metaOrder doesn't exist for the given order it means it was created // via dexcctl and the GUI isn't aware of it or it was an inflight order. // refreshActiveOrders must be called to grab this order. @@ -2514,19 +2582,24 @@ export default class MarketsPage extends BasePage { // and unlocked) has now become ready to tick. The active orders section // needs to be refreshed. const wasInflight = note.topic === 'AsyncOrderFailure' || note.topic === 'AsyncOrderSubmitted' - if (!mord || wasInflight || (note.topic === 'OrderLoaded' && order.readyToTick)) { + if (!mord || wasInflight || (note.topic === 'OrderLoaded' && ord.readyToTick)) { return this.refreshActiveOrders() } const oldStatus = mord.ord.status - mord.ord = order + mord.ord = ord if (note.topic === 'MissedCancel') Doc.show(mord.details.cancelBttn) - if (order.filled === order.qty) Doc.hide(mord.details.cancelBttn) - if (app().canAccelerateOrder(order)) Doc.show(mord.details.accelerateBttn) + if (ord.filled === ord.qty) Doc.hide(mord.details.cancelBttn) + if (app().canAccelerateOrder(ord)) Doc.show(mord.details.accelerateBttn) else Doc.hide(mord.details.accelerateBttn) this.updateMetaOrder(mord) // Only reset markers if there is a change, since the chart is redrawn. - if ((oldStatus === OrderUtil.StatusEpoch && order.status === OrderUtil.StatusBooked) || - (oldStatus === OrderUtil.StatusBooked && order.status > OrderUtil.StatusBooked)) this.setDepthMarkers() + if ( + (oldStatus === OrderUtil.StatusEpoch && ord.status === OrderUtil.StatusBooked) || + (oldStatus === OrderUtil.StatusBooked && ord.status > OrderUtil.StatusBooked) + ) { + this.setDepthMarkers() + this.updateReputation() + } } /* @@ -2693,7 +2766,7 @@ export default class MarketsPage extends BasePage { const page = this.page const lots = parseInt(page.lotField.value || '0') if (lots <= 0) { - page.lotField.value = '0' + page.lotField.value = page.lotField.value === '' ? '' : '0' page.qtyField.value = '' this.previewQuoteAmt(false) this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) diff --git a/client/webserver/site/src/js/orderutil.ts b/client/webserver/site/src/js/orderutil.ts index 3ea64c441c..eb5d99c016 100644 --- a/client/webserver/site/src/js/orderutil.ts +++ b/client/webserver/site/src/js/orderutil.ts @@ -9,9 +9,13 @@ import { import { BooleanOption, XYRangeOption } from './opts' import Doc from './doc' -export const Limit = 1 -export const Market = 2 -export const Cancel = 3 +export const Limit = 1 // TODO: Delete for the versions below +export const Market = 2 // TODO: Delete for the versions below +export const Cancel = 3 // TODO: Delete for the versions below + +export const OrderTypeLimit = 1 +export const OrderTypeMarket = 2 +export const OrderTypeCancel = 3 /* The time-in-force specifiers are a mirror of dex/order.TimeInForce. */ export const ImmediateTiF = 0 @@ -34,8 +38,11 @@ export const MatchComplete = 4 export const MatchConfirmed = 5 /* The match sides are a mirror of dex/order.MatchSide. */ -export const Maker = 0 -export const Taker = 1 +export const Maker = 0 // TODO: Delete for the versions below +export const Taker = 1 // TODO: Delete for the versions below + +export const MatchSideMaker = 0 +export const MatchSideTaker = 1 /* * RateEncodingFactor is used when encoding an atomic exchange rate as an diff --git a/client/webserver/site/src/js/register.ts b/client/webserver/site/src/js/register.ts index e8ec059851..b8df3f17d3 100644 --- a/client/webserver/site/src/js/register.ts +++ b/client/webserver/site/src/js/register.ts @@ -23,7 +23,7 @@ import State from './state' export default class RegistrationPage extends BasePage { body: HTMLElement pwCache: PasswordCache - currentDEX: Exchange + currentDEX: Exchange // TODO: Just use host and pull xc from app() as needed. page: Record loginForm: LoginForm appPassResetForm: AppPassResetForm @@ -77,7 +77,7 @@ export default class RegistrationPage extends BasePage { this.newWalletForm = new NewWalletForm( page.newWalletForm, - assetID => this.newWalletCreated(assetID), + assetID => this.newWalletCreated(assetID, this.confirmRegisterForm.tier), this.pwCache, () => this.animateRegAsset(page.newWalletForm) ) @@ -95,19 +95,18 @@ export default class RegistrationPage extends BasePage { } // SELECT REG ASSET - this.regAssetForm = new FeeAssetSelectionForm(page.regAssetForm, async assetID => { - this.confirmRegisterForm.setAsset(assetID) - + this.regAssetForm = new FeeAssetSelectionForm(page.regAssetForm, async (assetID: number, tier: number) => { const asset = app().assets[assetID] const wallet = asset.wallet if (wallet) { const bondAsset = this.currentDEX.bondAssets[asset.symbol] const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm) + this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer) if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + bondsFeeBuffer) { this.animateConfirmForm(page.regAssetForm) return } - this.walletWaitForm.setWallet(wallet, bondsFeeBuffer) + this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier) slideSwap(page.regAssetForm, page.walletWait) return } @@ -208,7 +207,7 @@ export default class RegistrationPage extends BasePage { await app().loadPage('markets') } - async newWalletCreated (assetID: number) { + async newWalletCreated (assetID: number, tier: number) { this.regAssetForm.refresh() const user = await app().fetchUser() if (!user) return @@ -223,7 +222,7 @@ export default class RegistrationPage extends BasePage { return } - this.walletWaitForm.setWallet(wallet, bondsFeeBuffer) + this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier) await slideSwap(page.newWalletForm, page.walletWait) } } diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 72b7852005..81962a0d96 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -7,7 +7,7 @@ declare global { localeDiscrepancies: () => void testFormatFourSigFigs: () => void testFormatRateFullPrecision: () => void - user: () => void + user: () => User isWebview?: () => boolean openUrl: (url: string) => void } @@ -56,6 +56,8 @@ export interface Exchange { viewOnly: boolean bondAssets: Record candleDurs: string[] + maxScore: number + penaltyThreshold: number } export interface Candle { @@ -82,6 +84,7 @@ export interface Market { quoteid: number quotesymbol: string lotsize: number + parcelsize: number ratestep: number epochlen: number startepoch: number @@ -353,6 +356,12 @@ export interface BondNote extends CoreNote { dex: string coinID: string | null tier: number | null + auth: ExchangeAuth | null +} + +export interface ReputationNote extends CoreNote { + host: string + rep: Reputation } export interface BalanceNote extends CoreNote { diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 9cd511dddc..2334e03d5b 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -76,23 +76,23 @@ export default class SettingsPage extends BasePage { }) // Asset selection - this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async (assetID: number) => { - this.confirmRegisterForm.setAsset(assetID) - + this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async (assetID: number, tier: number) => { const asset = app().assets[assetID] const wallet = asset.wallet if (wallet) { const bondAsset = this.currentDEX.bondAssets[asset.symbol] const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm) + this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer) if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + bondsFeeBuffer) { this.animateConfirmForm(page.regAssetForm) return } - this.walletWaitForm.setWallet(wallet, bondsFeeBuffer) + this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier) this.slideSwap(page.walletWait) return } + this.confirmRegisterForm.setAsset(assetID, tier, 0) this.newWalletForm.setAsset(assetID) this.slideSwap(page.newWalletForm) }) @@ -107,7 +107,7 @@ export default class SettingsPage extends BasePage { // Create a new wallet this.newWalletForm = new forms.NewWalletForm( page.newWalletForm, - assetID => this.newWalletCreated(assetID), + assetID => this.newWalletCreated(assetID, this.confirmRegisterForm.tier), this.pwCache, () => this.animateRegAsset(page.newWalletForm) ) @@ -255,7 +255,7 @@ export default class SettingsPage extends BasePage { return res.feeBuffer } - async newWalletCreated (assetID: number) { + async newWalletCreated (assetID: number, tier: number) { const user = await app().fetchUser() if (!user) return const page = this.page @@ -264,12 +264,13 @@ export default class SettingsPage extends BasePage { const bondAmt = this.currentDEX.bondAssets[asset.symbol].amount const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.newWalletForm) + this.confirmRegisterForm.setFees(assetID, bondsFeeBuffer) if (wallet.synced && wallet.balance.available >= 2 * bondAmt + bondsFeeBuffer) { await this.animateConfirmForm(page.newWalletForm) return } - this.walletWaitForm.setWallet(wallet, bondsFeeBuffer) + this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier) this.slideSwap(page.walletWait) } diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index 96e6e90ed0..351e200743 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -7,7 +7,8 @@ import { UnlockWalletForm, DepositAddress, bind as bindForm, - baseChainSymbol + baseChainSymbol, + showSuccess } from './forms' import State from './state' import * as intl from './locales' @@ -751,25 +752,12 @@ export default class WalletsPage extends BasePage { } async showSuccess (msg: string) { - const page = this.page - page.successMessage.textContent = msg - this.currentForm = page.checkmarkForm this.forms.forEach(form => Doc.hide(form)) - Doc.show(page.forms, page.checkmarkForm) - page.checkmarkForm.style.right = '0' - page.checkmark.style.fontSize = '0px' - - const [startR, startG, startB] = State.isDark() ? [223, 226, 225] : [51, 51, 51] - const [endR, endG, endB] = [16, 163, 16] - const [diffR, diffG, diffB] = [endR - startR, endG - startG, endB - startB] - - this.animation = new Animation(1200, (prog: number) => { - page.checkmark.style.fontSize = `${prog * 80}px` - page.checkmark.style.color = `rgb(${startR + prog * diffR}, ${startG + prog * diffG}, ${startB + prog * diffB})` - }, 'easeOutElastic', () => { - this.animation = new Animation(1500, () => { /* pass */ }, '', () => { - if (this.currentForm === page.checkmarkForm) this.closePopups() - }) + this.currentForm = this.page.checkmarkForm + this.animation = showSuccess(this.page, msg) + await this.animation.wait() + this.animation = new Animation(1500, () => { /* pass */ }, '', () => { + if (this.currentForm === this.page.checkmarkForm) this.closePopups() }) }