From 6b47dc57f365ee2790bfc24796009e529cc9bf78 Mon Sep 17 00:00:00 2001 From: John Williams Date: Fri, 11 Sep 2026 17:15:27 -0400 Subject: [PATCH] feat(hostd): replace per-price pin toggles with a single switch --- .changeset/pin-all-prices-or-none.md | 5 + .../src/fixtures/configResetAllSettings.ts | 27 +- .../src/fixtures/configSetMixedPinning.ts | 59 ++++ apps/hostd-e2e/src/specs/config.spec.ts | 65 ++++- .../src/specs/configLegacyPinning.spec.ts | 134 +++++++++ .../components/Config/PricingSetting.tsx | 106 +++++++ apps/hostd/components/Config/index.tsx | 274 +++++------------- apps/hostd/contexts/config/fields.tsx | 50 ++-- apps/hostd/contexts/config/index.tsx | 2 + apps/hostd/contexts/config/transform.spec.ts | 174 ++++++++++- apps/hostd/contexts/config/transform.ts | 59 +++- apps/hostd/contexts/config/types.ts | 18 +- apps/hostd/contexts/config/useOnValid.tsx | 4 +- libs/e2e/src/fixtures/textInput.ts | 7 + 14 files changed, 683 insertions(+), 301 deletions(-) create mode 100644 .changeset/pin-all-prices-or-none.md create mode 100644 apps/hostd-e2e/src/fixtures/configSetMixedPinning.ts create mode 100644 apps/hostd-e2e/src/specs/configLegacyPinning.spec.ts create mode 100644 apps/hostd/components/Config/PricingSetting.tsx diff --git a/.changeset/pin-all-prices-or-none.md b/.changeset/pin-all-prices-or-none.md new file mode 100644 index 000000000..e435df73c --- /dev/null +++ b/.changeset/pin-all-prices-or-none.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +Price pinning is now all or nothing - the per-price pin toggles have been replaced with a single Pin prices switch that pins storage price, egress price, ingress price, and max collateral together. Hosts that currently have only some of their prices pinned keep that configuration until they change pinning themselves. diff --git a/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts b/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts index 02016e859..0f80f4c45 100644 --- a/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts +++ b/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts @@ -25,31 +25,20 @@ export const configResetAllSettings = step( await fillSelectInputByName(page, 'pinnedCurrency', 'USD') await fillTextInputByName(page, 'pinnedThreshold', '3') - await setSwitchByLabel(page, 'shouldPinStoragePrice', false) - await fillTextInputByName(page, 'storagePrice', '10') - await setSwitchByLabel(page, 'shouldPinStoragePrice', true) + // Pinning is all or nothing - set the pinned values, then turn pinning + // off so the siacoin values are the ones in effect. + await setSwitchByLabel(page, 'shouldPinPrices', true) await fillTextInputByName(page, 'storagePricePinned', '5') - await setSwitchByLabel(page, 'shouldPinStoragePrice', false) - - await setSwitchByLabel(page, 'shouldPinEgressPrice', false) - await fillTextInputByName(page, 'egressPrice', '10') - await setSwitchByLabel(page, 'shouldPinEgressPrice', true) await fillTextInputByName(page, 'egressPricePinned', '5') - await setSwitchByLabel(page, 'shouldPinEgressPrice', false) - - await setSwitchByLabel(page, 'shouldPinIngressPrice', false) - await fillTextInputByName(page, 'ingressPrice', '10') - await setSwitchByLabel(page, 'shouldPinIngressPrice', true) await fillTextInputByName(page, 'ingressPricePinned', '5') - await setSwitchByLabel(page, 'shouldPinIngressPrice', false) + await fillTextInputByName(page, 'maxCollateralPinned', '5') + await setSwitchByLabel(page, 'shouldPinPrices', false) + await fillTextInputByName(page, 'storagePrice', '10') + await fillTextInputByName(page, 'egressPrice', '10') + await fillTextInputByName(page, 'ingressPrice', '10') await fillTextInputByName(page, 'collateralMultiplier', '2') - - await setSwitchByLabel(page, 'shouldPinMaxCollateral', false) await fillTextInputByName(page, 'maxCollateral', '10') - await setSwitchByLabel(page, 'shouldPinMaxCollateral', true) - await fillTextInputByName(page, 'maxCollateralPinned', '5') - await setSwitchByLabel(page, 'shouldPinMaxCollateral', false) await fillTextInputByName(page, 'contractPrice', '0.2') await fillTextInputByName(page, 'baseRPCPrice', '1') diff --git a/apps/hostd-e2e/src/fixtures/configSetMixedPinning.ts b/apps/hostd-e2e/src/fixtures/configSetMixedPinning.ts new file mode 100644 index 000000000..03e7978fb --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/configSetMixedPinning.ts @@ -0,0 +1,59 @@ +import Axios from 'axios' +import { Page } from '@playwright/test' +import { clusterd } from '@siafoundation/clusterd' +import { step } from '@siafoundation/e2e' + +/** + * Sets a pinning configuration where only some prices are pinned, which the UI + * no longer allows the host to create. Hosts configured before pinning became + * all or nothing can still be in this state, so it is set directly on the + * daemon to test how the UI presents and resolves it. + */ +export const configSetMixedPinning = step( + 'set mixed pinning configuration', + async ({ page }: { page: Page }) => { + const hostdNode = clusterd.nodes.find((n) => n.type === 'hostd') + await Axios.put( + `${hostdNode.apiAddress}/api/settings/pinned`, + { + currency: 'usd', + threshold: 0.02, + storage: { pinned: true, value: 5 }, + egress: { pinned: false, value: 0 }, + ingress: { pinned: false, value: 0 }, + maxCollateral: { pinned: false, value: 0 }, + }, + { + auth: { username: '', password: hostdNode.password }, + timeout: 10_000, + }, + ) + // The app has already fetched and cached the settings during login, so + // reload to pick up the configuration that was just set on the daemon. + await page.reload() + }, +) + +/** + * The pinning configuration as reported by the daemon, for asserting what was + * actually saved rather than what the UI displays. + */ +export const getPinnedSettings = step( + 'get pinned settings', + async (): Promise<{ + storage: { pinned: boolean } + egress: { pinned: boolean } + ingress: { pinned: boolean } + maxCollateral: { pinned: boolean } + }> => { + const hostdNode = clusterd.nodes.find((n) => n.type === 'hostd') + const response = await Axios.get( + `${hostdNode.apiAddress}/api/settings/pinned`, + { + auth: { username: '', password: hostdNode.password }, + timeout: 10_000, + }, + ) + return response.data + }, +) diff --git a/apps/hostd-e2e/src/specs/config.spec.ts b/apps/hostd-e2e/src/specs/config.spec.ts index 82d4c2651..603410e64 100644 --- a/apps/hostd-e2e/src/specs/config.spec.ts +++ b/apps/hostd-e2e/src/specs/config.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test' import { setViewMode, expectTextInputByName, + expectTextInputVisible, expectTextInputNotVisible, fillTextInputByName, fillSelectInputByName, @@ -32,9 +33,9 @@ test('basic field change and save behaviour', async ({ page }) => { await fillTextInputByName(page, 'maxContractDuration', '7') await fillSelectInputByName(page, 'pinnedCurrency', 'BTC') await fillTextInputByName(page, 'pinnedThreshold', '7') - await setSwitchByLabel(page, 'shouldPinStoragePrice', true) + await setSwitchByLabel(page, 'shouldPinPrices', true) await fillTextInputByName(page, 'storagePricePinned', '77') - await fillTextInputByName(page, 'egressPrice', '77') + await fillTextInputByName(page, 'egressPricePinned', '77') await fillTextInputByName(page, 'baseRPCPrice', '77') // Correct number of changes is shown. @@ -52,30 +53,62 @@ test('basic field change and save behaviour', async ({ page }) => { await expectTextInputByName(page, 'maxContractDuration', '7') await fillSelectInputByName(page, 'pinnedCurrency', 'USD') await expectTextInputByName(page, 'pinnedThreshold', '7') - // Pinned vs not pinned fields correctly shown or hidden. - await expectSwitchByLabel(page, 'shouldPinStoragePrice', true) + // Pinning applies to every price at once, so all of the pinned fields are + // shown and none of the siacoin fields are. + await expectSwitchByLabel(page, 'shouldPinPrices', true) await expectTextInputByName(page, 'storagePricePinned', '$77') await expectTextInputNotVisible(page, 'storagePrice') - await expectSwitchByLabel(page, 'shouldPinEgressPrice', false) - await expectTextInputByName(page, 'egressPrice', '77') - await expectTextInputNotVisible(page, 'egressPricePinned') + await expectTextInputByName(page, 'egressPricePinned', '$77') + await expectTextInputNotVisible(page, 'egressPrice') + await expectTextInputNotVisible(page, 'ingressPrice') + await expectTextInputNotVisible(page, 'maxCollateral') await expectTextInputByName(page, 'baseRPCPrice', '77') }) -test('pin switches should show in both view modes', async ({ page }) => { +test('pinning applies to all prices at once', async ({ page }) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + + // With pinning off every price is configured in siacoin. + await setSwitchByLabel(page, 'shouldPinPrices', false) + await expectTextInputVisible(page, 'storagePrice') + await expectTextInputVisible(page, 'egressPrice') + await expectTextInputVisible(page, 'ingressPrice') + await expectTextInputVisible(page, 'maxCollateral') + await expectTextInputNotVisible(page, 'storagePricePinned') + + // Turning pinning on switches all of them to fiat. + await setSwitchByLabel(page, 'shouldPinPrices', true) + await expectTextInputVisible(page, 'storagePricePinned') + await expectTextInputVisible(page, 'egressPricePinned') + await expectTextInputVisible(page, 'ingressPricePinned') + await expectTextInputVisible(page, 'maxCollateralPinned') + await expectTextInputNotVisible(page, 'storagePrice') +}) + +test('pin switch should show in both view modes', async ({ page }) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'basic' }) + await expectSwitchVisible(page, 'shouldPinPrices') + + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + await expectSwitchVisible(page, 'shouldPinPrices') +}) + +test('pinned max collateral should show in both view modes', async ({ + page, +}) => { + // Max collateral must be configurable wherever pinning is, otherwise it can + // be pinned without the host being able to see or set its value. await navigateToConfig({ page }) await setViewMode({ page, state: 'basic' }) - await expectSwitchVisible(page, 'shouldPinStoragePrice') - await expectSwitchVisible(page, 'shouldPinEgressPrice') - await expectSwitchVisible(page, 'shouldPinIngressPrice') - await expectSwitchVisible(page, 'shouldPinMaxCollateral') + await setSwitchByLabel(page, 'shouldPinPrices', true) + await expectTextInputVisible(page, 'maxCollateralPinned') await navigateToConfig({ page }) await setViewMode({ page, state: 'advanced' }) - await expectSwitchVisible(page, 'shouldPinStoragePrice') - await expectSwitchVisible(page, 'shouldPinEgressPrice') - await expectSwitchVisible(page, 'shouldPinIngressPrice') - await expectSwitchVisible(page, 'shouldPinMaxCollateral') + await expectTextInputVisible(page, 'maxCollateralPinned') }) test('dynamic max collateral suggestion', async ({ page }) => { diff --git a/apps/hostd-e2e/src/specs/configLegacyPinning.spec.ts b/apps/hostd-e2e/src/specs/configLegacyPinning.spec.ts new file mode 100644 index 000000000..7b28c8eb2 --- /dev/null +++ b/apps/hostd-e2e/src/specs/configLegacyPinning.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/test' +import { + setViewMode, + expectTextInputVisible, + expectTextInputNotVisible, + expectSwitchByLabel, + fillTextInputByName, +} from '@siafoundation/e2e' +import { navigateToConfig } from '../fixtures/navigate' +import { afterTest, beforeTest } from '../fixtures/beforeTest' +import { + configSetMixedPinning, + getPinnedSettings, +} from '../fixtures/configSetMixedPinning' + +// Pinning is all or nothing, but hosts configured before that was enforced can +// still have only some of their prices pinned. That configuration is preserved +// until the host changes pinning themselves. +test.beforeEach(async ({ page }) => { + await beforeTest(page) + await configSetMixedPinning({ page }) +}) + +test.afterEach(async () => { + await afterTest() +}) + +test('a mixed pinning configuration is shown as unpinned with a notice', async ({ + page, +}) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + + // The toggle reads off because pinning is not in effect for every price. + await expectSwitchByLabel(page, 'shouldPinPrices', false) + await expect( + page.getByText('Some of your prices are pinned to fiat values'), + ).toBeVisible() + + // Each price still shows the value that is actually in effect for it, so the + // pinned one shows its fiat input and the rest show siacoin. + await expectTextInputVisible(page, 'storagePricePinned') + await expectTextInputNotVisible(page, 'storagePrice') + await expectTextInputVisible(page, 'egressPrice') + await expectTextInputVisible(page, 'ingressPrice') + await expectTextInputVisible(page, 'maxCollateral') + + // The pinned price is called out. + await expect( + page.getByTestId('storagePriceGroup').getByText('Pinned'), + ).toBeVisible() + await expect( + page.getByTestId('egressPriceGroup').getByText('Pinned'), + ).toBeHidden() +}) + +test('a mixed pinning configuration survives an unrelated change', async ({ + page, +}) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + + // Change something that has nothing to do with pinning and save. + await fillTextInputByName(page, 'baseRPCPrice', '55') + await page.getByText('Save changes').click() + await expect(page.getByText('1 change')).toBeHidden() + + // The host's existing pinning configuration is untouched. + const settings = await getPinnedSettings() + expect(settings.storage.pinned).toBe(true) + expect(settings.egress.pinned).toBe(false) + expect(settings.ingress.pinned).toBe(false) + expect(settings.maxCollateral.pinned).toBe(false) + + // And the notice is still shown after the save. + await expect( + page.getByText('Some of your prices are pinned to fiat values'), + ).toBeVisible() +}) + +test('turning pinning on resolves a mixed configuration to all pinned', async ({ + page, +}) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + + await page.getByRole('button', { name: 'Pin all', exact: true }).click() + + // Resolving the mixed configuration takes the notice away and counts as a + // change on its own, so the host can save the choice immediately. + await expect( + page.getByText('Some of your prices are pinned to fiat values'), + ).toBeHidden() + await expect(page.getByText('Save changes')).toBeEnabled() + + // Every price now needs a fiat value. + await fillTextInputByName(page, 'egressPricePinned', '2') + await fillTextInputByName(page, 'ingressPricePinned', '1') + await fillTextInputByName(page, 'maxCollateralPinned', '100') + await page.getByText('Save changes').click() + + await expect(async () => { + const settings = await getPinnedSettings() + expect(settings.storage.pinned).toBe(true) + expect(settings.egress.pinned).toBe(true) + expect(settings.ingress.pinned).toBe(true) + expect(settings.maxCollateral.pinned).toBe(true) + }).toPass() +}) + +test('turning pinning off resolves a mixed configuration to none pinned', async ({ + page, +}) => { + await navigateToConfig({ page }) + await setViewMode({ page, state: 'advanced' }) + + // Unpinning is a single click even though the switch already reads off. + await page.getByRole('button', { name: 'Unpin all' }).click() + + await expect( + page.getByText('Some of your prices are pinned to fiat values'), + ).toBeHidden() + // The choice is a change on its own, so no other edit is needed to save it. + await expect(page.getByText('Save changes')).toBeEnabled() + await page.getByText('Save changes').click() + + await expect(async () => { + const settings = await getPinnedSettings() + expect(settings.storage.pinned).toBe(false) + expect(settings.egress.pinned).toBe(false) + expect(settings.ingress.pinned).toBe(false) + expect(settings.maxCollateral.pinned).toBe(false) + }).toPass() +}) diff --git a/apps/hostd/components/Config/PricingSetting.tsx b/apps/hostd/components/Config/PricingSetting.tsx new file mode 100644 index 000000000..5f11cec28 --- /dev/null +++ b/apps/hostd/components/Config/PricingSetting.tsx @@ -0,0 +1,106 @@ +import { + Badge, + Button, + ConfigFields, + ConfigurationFiat, + ConfigurationSiacoin, + PanelMenuSetting, + Text, +} from '@siafoundation/design-system' +import { Warning16 } from '@siafoundation/react-icons' +import { UseFormReturn } from 'react-hook-form' +import { CurrencyId } from '@siafoundation/react-core' +import { SettingsData } from '../../contexts/config/types' + +/** + * A price that can be pinned to a fiat value. Shows the fiat input when the + * price is pinned and the siacoin input otherwise. Prices are pinned all + * together, except on hosts with a mixed configuration saved before that was + * enforced, where an individually pinned price is called out with a badge. + */ +export function PricingSetting({ + id, + title, + form, + fields, + name, + pinnedName, + pinned, + showLegacyPinnedBadge, + pinnedCurrency, +}: { + id: string + title: string + form: UseFormReturn + fields: ConfigFields + name: keyof SettingsData + pinnedName: keyof SettingsData + pinned: boolean + showLegacyPinnedBadge: boolean + pinnedCurrency?: CurrencyId | '' +}) { + return ( + + {showLegacyPinnedBadge && ( +
+ + Pinned + +
+ )} + {pinned ? ( + + ) : ( + + )} + + } + /> + ) +} + +/** + * Shown when the daemon has a mixed pinning configuration, ie one saved before + * pinning became all or nothing. The host resolves it by choosing to pin every + * price or none of them. + */ +export function MixedPinningNotice({ + form, +}: { + form: UseFormReturn +}) { + const setShouldPinPrices = (value: boolean) => + form.setValue('shouldPinPrices', value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + return ( +
+ + + +
+ + Some of your prices are pinned to fiat values and some are not. + Pinning is now all or nothing - pin all of your prices or unpin all of + them. Your current configuration is preserved. + +
+ + +
+
+
+ ) +} diff --git a/apps/hostd/components/Config/index.tsx b/apps/hostd/components/Config/index.tsx index ffe7aeb50..597f9d959 100644 --- a/apps/hostd/components/Config/index.tsx +++ b/apps/hostd/components/Config/index.tsx @@ -1,41 +1,41 @@ import { - Text, ConfigurationPanel, PanelMenuSection, - PanelMenuSetting, - FieldSwitch, ConfigurationPanelSetting, shouldShowField, - Tooltip, - ConfigurationSiacoin, - ConfigurationFiat, + Alert, } from '@siafoundation/design-system' import { useWatch } from 'react-hook-form' import { useConfig } from '../../contexts/config' +import { PinnablePrice } from '../../contexts/config/types' import { StateConnError } from './StateConnError' +import { MixedPinningNotice, PricingSetting } from './PricingSetting' export function Config() { - const { fields, form, remoteError, configRef } = useConfig() - const shouldPinStoragePrice = useWatch({ + const { + fields, + form, + remoteError, + configRef, + pinningEnabled, + settingsPinned, + } = useConfig() + const shouldPinPrices = useWatch({ control: form.control, - name: 'shouldPinStoragePrice', - }) - const shouldPinEgressPrice = useWatch({ - control: form.control, - name: 'shouldPinEgressPrice', - }) - const shouldPinIngressPrice = useWatch({ - control: form.control, - name: 'shouldPinIngressPrice', - }) - const shouldPinMaxCollateral = useWatch({ - control: form.control, - name: 'shouldPinMaxCollateral', + name: 'shouldPinPrices', }) const pinnedCurrency = useWatch({ control: form.control, name: 'pinnedCurrency', }) + // Until the host resolves a mixed configuration `shouldPinPrices` is null, + // and each price keeps the pinned state the daemon reports for it so the + // host can see what is pinned. Without the explorer there is no exchange + // rate and pinned settings are never saved, so every price is in siacoin. + const isPinned = (price: PinnablePrice) => + !!pinningEnabled && + (shouldPinPrices ?? !!settingsPinned.data?.[price].pinned) + const showMixedPinningNotice = !!pinningEnabled && shouldPinPrices === null return remoteError ? ( ) : ( @@ -47,6 +47,17 @@ export function Config() { form={form} /> + + {showMixedPinningNotice && ( + + + + )} - - {shouldShowField({ - form, - fields, - name: 'shouldPinStoragePrice', - }) && ( - -
- - Pin - - -
-
- )} - {shouldShowField({ - form, - fields, - name: 'shouldPinStoragePrice', - }) && shouldPinStoragePrice ? ( - - ) : ( - - )} - + form={form} + fields={fields} + name="storagePrice" + pinnedName="storagePricePinned" + pinned={isPinned('storage')} + showLegacyPinnedBadge={ + showMixedPinningNotice && !!settingsPinned.data?.storage.pinned } + pinnedCurrency={pinnedCurrency} /> - - {shouldShowField({ - form, - fields, - name: 'shouldPinEgressPrice', - }) && ( - -
- - Pin - - -
-
- )} - {shouldShowField({ - form, - fields, - name: 'shouldPinEgressPrice', - }) && shouldPinEgressPrice ? ( - - ) : ( - - )} - + form={form} + fields={fields} + name="egressPrice" + pinnedName="egressPricePinned" + pinned={isPinned('egress')} + showLegacyPinnedBadge={ + showMixedPinningNotice && !!settingsPinned.data?.egress.pinned } + pinnedCurrency={pinnedCurrency} /> - - {shouldShowField({ - form, - fields, - name: 'shouldPinIngressPrice', - }) && ( - -
- - Pin - - -
-
- )} - {shouldShowField({ - form, - fields, - name: 'shouldPinIngressPrice', - }) && shouldPinIngressPrice ? ( - - ) : ( - - )} - + form={form} + fields={fields} + name="ingressPrice" + pinnedName="ingressPricePinned" + pinned={isPinned('ingress')} + showLegacyPinnedBadge={ + showMixedPinningNotice && !!settingsPinned.data?.ingress.pinned } + pinnedCurrency={pinnedCurrency} /> - {shouldShowField({ - form, - fields, - name: 'shouldPinMaxCollateral', - }) && ( - -
- - Pin - - -
-
- )} - {shouldShowField({ - form, - fields, - name: 'shouldPinMaxCollateral', - }) && shouldPinMaxCollateral ? ( - - ) : ( - - )} - + form={form} + fields={fields} + name="maxCollateral" + pinnedName="maxCollateralPinned" + pinned={isPinned('maxCollateral')} + showLegacyPinnedBadge={ + showMixedPinningNotice && + !!settingsPinned.data?.maxCollateral.pinned } + pinnedCurrency={pinnedCurrency} /> )} ({ @@ -205,9 +205,17 @@ export function getFields({ }, }, - shouldPinStoragePrice: { - title: 'Pin storage price', - description: '', + shouldPinPrices: { + title: 'Pin prices', + description: ( + <> + Pin your prices to fixed fiat values. hostd tracks the exchange rate + and keeps the siacoin price in sync with the fiat value, so your fiat + revenue stays consistent as the price of siacoin moves. Pinning + applies to all of your prices at once - storage, egress, ingress, and + max collateral are pinned together or not at all. + + ), type: 'boolean', category: 'pricing', hidden: !pinningEnabled, @@ -253,7 +261,7 @@ export function getFields({ range: requiredIfPinningEnabled( validationContext, (value: BigNumber, values) => - !values.shouldPinStoragePrice || + values.shouldPinPrices === false || value?.gte(0) || 'storage price must not be negative', ), @@ -261,14 +269,6 @@ export function getFields({ }, }, - shouldPinEgressPrice: { - title: 'Pin egress price', - description: '', - type: 'boolean', - category: 'pricing', - hidden: !pinningEnabled, - validation: {}, - }, egressPrice: { title: 'Egress price', description: ( @@ -310,7 +310,7 @@ export function getFields({ range: requiredIfPinningEnabled( validationContext, (value: BigNumber, values) => - !values.shouldPinEgressPrice || + values.shouldPinPrices === false || value?.gte(0) || 'egress price must not be negative', ), @@ -318,14 +318,6 @@ export function getFields({ }, }, - shouldPinIngressPrice: { - title: 'Pin ingress price', - description: '', - type: 'boolean', - category: 'pricing', - hidden: !pinningEnabled, - validation: {}, - }, ingressPrice: { title: 'Ingress price', description: ( @@ -366,7 +358,7 @@ export function getFields({ range: requiredIfPinningEnabled( validationContext, (value: BigNumber, values) => - !values.shouldPinIngressPrice || + values.shouldPinPrices === false || value?.gte(0) || 'ingress price must not be negative', ), @@ -391,14 +383,6 @@ export function getFields({ }, }, - shouldPinMaxCollateral: { - title: 'Pin max collateral', - description: '', - type: 'boolean', - category: 'pricing', - hidden: !pinningEnabled, - validation: {}, - }, maxCollateral: { title: 'Max collateral', description: ( @@ -427,7 +411,7 @@ export function getFields({ description: '', type: 'fiat', category: 'pricing', - hidden: !pinningEnabled || configViewMode === 'basic', + hidden: !pinningEnabled, validation: { validate: { required: requiredIfPinningEnabled(validationContext), @@ -439,7 +423,7 @@ export function getFields({ range: requiredIfPinningEnabled( validationContext, (value: BigNumber, values) => - !values.shouldPinMaxCollateral || + values.shouldPinPrices === false || value?.gte(0) || 'max collateral must not be negative', ), diff --git a/apps/hostd/contexts/config/index.tsx b/apps/hostd/contexts/config/index.tsx index bf5514c21..62856972a 100644 --- a/apps/hostd/contexts/config/index.tsx +++ b/apps/hostd/contexts/config/index.tsx @@ -56,6 +56,7 @@ export function useConfigMain() { const state = useHostState() const pinningEnabled = state.data?.explorer.enabled + const revalidateAndResetForm = useCallback(async () => { const _settings = await settings.mutate() const _settingsPinned = await settingsPinned.mutate() @@ -111,6 +112,7 @@ export function useConfigMain() { return { fields, settings, + settingsPinned, dynDNSCheck, changeCount, revalidateAndResetForm, diff --git a/apps/hostd/contexts/config/transform.spec.ts b/apps/hostd/contexts/config/transform.spec.ts index a7603ed7b..8f23053e4 100644 --- a/apps/hostd/contexts/config/transform.spec.ts +++ b/apps/hostd/contexts/config/transform.spec.ts @@ -1,11 +1,52 @@ import BigNumber from 'bignumber.js' import { calculateMaxCollateral, + getShouldPinPrices, transformDown, transformUpSettings, transformUpSettingsPinned, } from './transform' +const pinnedValues = { + // settings + acceptingContracts: true, + netAddress: 'tabo.zen.sia.tech:9882', + maxContractDuration: new BigNumber('6'), + contractPrice: new BigNumber('0.2'), + baseRPCPrice: new BigNumber('1'), + sectorAccessPrice: new BigNumber('1'), + collateralMultiplier: new BigNumber('2'), + maxCollateral: new BigNumber('1000'), + storagePrice: new BigNumber('50'), + egressPrice: new BigNumber('250'), + ingressPrice: new BigNumber('10'), + priceTableValidity: new BigNumber('30'), + accountExpiry: new BigNumber('30'), + maxAccountBalance: new BigNumber('10'), + ingressLimit: new BigNumber('0'), + egressLimit: new BigNumber('0'), + dnsProvider: 'route53' as const, + dnsIpv4: false, + dnsIpv6: false, + dnsDuckDnsToken: '', + dnsNoIpEmail: '', + dnsNoIpPassword: '', + dnsAwsId: 'ID', + dnsAwsSecret: 'secret', + dnsAwsZoneId: 'zone', + dnsCloudflareToken: '', + dnsCloudflareZoneId: '', + + // settings pinned + pinnedCurrency: 'usd' as const, + pinnedThreshold: new BigNumber('2'), + shouldPinPrices: false, + storagePricePinned: new BigNumber('5'), + egressPricePinned: new BigNumber('2'), + ingressPricePinned: new BigNumber('1'), + maxCollateralPinned: new BigNumber('100'), +} + describe('data transforms', () => { it('down', () => { expect( @@ -87,13 +128,10 @@ describe('data transforms', () => { // settingsPinned pinnedCurrency: 'jpy', pinnedThreshold: new BigNumber('10'), - shouldPinStoragePrice: false, + shouldPinPrices: null, storagePricePinned: new BigNumber('0'), - shouldPinEgressPrice: true, egressPricePinned: new BigNumber('400.50'), - shouldPinIngressPrice: false, ingressPricePinned: new BigNumber('0'), - shouldPinMaxCollateral: false, maxCollateralPinned: new BigNumber('0'), }) }) @@ -142,13 +180,10 @@ describe('data transforms', () => { // settings pinned pinnedCurrency: 'jpy', pinnedThreshold: new BigNumber('10'), - shouldPinStoragePrice: false, + shouldPinPrices: null, storagePricePinned: new BigNumber('0'), - shouldPinEgressPrice: true, egressPricePinned: new BigNumber('400.50'), - shouldPinIngressPrice: false, ingressPricePinned: new BigNumber('0'), - shouldPinMaxCollateral: false, maxCollateralPinned: new BigNumber('0'), }, { ddns: { provider: 'invalid' }, foobar: 'foobar' }, @@ -228,13 +263,10 @@ describe('data transforms', () => { // settings pinned pinnedCurrency: 'jpy', pinnedThreshold: new BigNumber('10'), - shouldPinStoragePrice: false, + shouldPinPrices: false, storagePricePinned: new BigNumber('0'), - shouldPinEgressPrice: false, egressPricePinned: new BigNumber('400.50'), - shouldPinIngressPrice: false, ingressPricePinned: new BigNumber('0'), - shouldPinMaxCollateral: false, maxCollateralPinned: new BigNumber('0'), }, { other: { pinned: true, value: 200 }, foobar: 'foobar' }, @@ -266,6 +298,124 @@ describe('data transforms', () => { }) }) + it('up settings pinned applies pinning to all prices', () => { + expect( + transformUpSettingsPinned( + { ...pinnedValues, shouldPinPrices: true }, + undefined, + ), + ).toMatchObject({ + storage: { pinned: true }, + egress: { pinned: true }, + ingress: { pinned: true }, + maxCollateral: { pinned: true }, + }) + }) + + it('up settings pinned keeps a mixed configuration until the host chooses', () => { + // The host edited something else in the config and has not made an all or + // nothing choice, so their existing configuration is written back as is. + expect( + transformUpSettingsPinned( + { ...pinnedValues, shouldPinPrices: null }, + { + storage: { pinned: true, value: 5 }, + egress: { pinned: false, value: 0 }, + ingress: { pinned: false, value: 0 }, + maxCollateral: { pinned: false, value: 0 }, + }, + ), + ).toMatchObject({ + storage: { pinned: true }, + egress: { pinned: false }, + ingress: { pinned: false }, + maxCollateral: { pinned: false }, + }) + }) + + it('up settings pinned resolves a mixed configuration once the host chooses', () => { + // Unpinning all and saving unpins the previously pinned price. + expect( + transformUpSettingsPinned( + { ...pinnedValues, shouldPinPrices: false }, + { + storage: { pinned: true, value: 5 }, + egress: { pinned: false, value: 0 }, + ingress: { pinned: false, value: 0 }, + maxCollateral: { pinned: false, value: 0 }, + }, + ), + ).toMatchObject({ + storage: { pinned: false }, + egress: { pinned: false }, + ingress: { pinned: false }, + maxCollateral: { pinned: false }, + }) + }) + + it('down without pinned settings does not read as a mixed configuration', () => { + // The pinned settings request can error, in which case the form falls back + // to its defaults. That must not look like a mixed configuration, which + // would show the notice and let an unrelated save unpin every price. + const values = transformDown({ + settings: { + acceptingContracts: true, + netAddress: 'tabo.zen.sia.tech:9882', + maxContractDuration: 25920, + contractPrice: '200000000000000000000000', + baseRPCPrice: '1000000000000000000', + sectorAccessPrice: '1000000000000000000', + collateralMultiplier: 2, + maxCollateral: '1000000000000000000000000000', + storagePrice: '10526559048', + egressPrice: '227373675443232', + ingressPrice: '9094947017729', + priceTableValidity: 1800000000000, + accountExpiry: 2592000000000000, + maxAccountBalance: '10000000000000000000000000', + ingressLimit: 0, + egressLimit: 0, + ddns: { + provider: '', + ipv4: false, + ipv6: false, + options: {}, + }, + revision: 0, + }, + settingsPinned: undefined, + } as Parameters[0]) + expect(values.shouldPinPrices).toBe(false) + }) + + it('reads pinning as all, none, or an unresolved mixed configuration', () => { + const settingsPinned = ( + storage: boolean, + egress: boolean, + ingress: boolean, + maxCollateral: boolean, + ) => ({ + currency: 'usd', + threshold: 0.02, + storage: { pinned: storage, value: 5 }, + egress: { pinned: egress, value: 0 }, + ingress: { pinned: ingress, value: 0 }, + maxCollateral: { pinned: maxCollateral, value: 0 }, + }) + expect(getShouldPinPrices(settingsPinned(true, true, true, true))).toBe( + true, + ) + expect(getShouldPinPrices(settingsPinned(false, false, false, false))).toBe( + false, + ) + expect( + getShouldPinPrices(settingsPinned(true, false, false, false)), + ).toBeNull() + expect( + getShouldPinPrices(settingsPinned(true, true, true, false)), + ).toBeNull() + }) + it('max collateral', () => { expect( calculateMaxCollateral(new BigNumber('400'), new BigNumber(2), 10), diff --git a/apps/hostd/contexts/config/transform.ts b/apps/hostd/contexts/config/transform.ts index 532839da7..a8eade535 100644 --- a/apps/hostd/contexts/config/transform.ts +++ b/apps/hostd/contexts/config/transform.ts @@ -138,24 +138,41 @@ export function transformUpSettingsPinned( // eslint-disable-next-line @typescript-eslint/no-explicit-any existingValues: any, ): HostSettingsPinned { + // A host with a mixed pinning configuration has not made an all or nothing + // choice yet, so their existing configuration is kept until they do. This + // means unrelated edits elsewhere in the config do not pin or unpin prices. + const pinned = + values.shouldPinPrices === null + ? { + storage: existingValues?.storage?.pinned ?? false, + egress: existingValues?.egress?.pinned ?? false, + ingress: existingValues?.ingress?.pinned ?? false, + maxCollateral: existingValues?.maxCollateral?.pinned ?? false, + } + : { + storage: values.shouldPinPrices, + egress: values.shouldPinPrices, + ingress: values.shouldPinPrices, + maxCollateral: values.shouldPinPrices, + } return { ...existingValues, currency: values.pinnedCurrency, threshold: values.pinnedThreshold.div(100).toNumber(), storage: { - pinned: values.shouldPinStoragePrice, + pinned: pinned.storage, value: values.storagePricePinned.toNumber(), }, ingress: { - pinned: values.shouldPinIngressPrice, + pinned: pinned.ingress, value: values.ingressPricePinned.toNumber(), }, egress: { - pinned: values.shouldPinEgressPrice, + pinned: pinned.egress, value: values.egressPricePinned.toNumber(), }, maxCollateral: { - pinned: values.shouldPinMaxCollateral, + pinned: pinned.maxCollateral, value: values.maxCollateralPinned.toNumber(), }, } @@ -263,24 +280,44 @@ export function transformDown({ ? { pinnedCurrency: settingsPinned.currency, pinnedThreshold: new BigNumber(settingsPinned.threshold).times(100), - shouldPinMaxCollateral: settingsPinned.maxCollateral.pinned, + // Pinning reads as on when every price is pinned and off when none + // are. A mixed configuration reads as `null`, which the switch shows + // as off but which tells the form the host has not chosen yet. + shouldPinPrices: getShouldPinPrices(settingsPinned), maxCollateralPinned: new BigNumber( settingsPinned.maxCollateral.value, ), - - shouldPinStoragePrice: settingsPinned.storage.pinned, storagePricePinned: new BigNumber(settingsPinned.storage.value), - - shouldPinEgressPrice: settingsPinned.egress.pinned, egressPricePinned: new BigNumber(settingsPinned.egress.value), - - shouldPinIngressPrice: settingsPinned.ingress.pinned, ingressPricePinned: new BigNumber(settingsPinned.ingress.value), } : defaultValuesSettingsPinned), } } +/** + * Whether every price is pinned, no price is pinned, or the daemon has a mixed + * configuration saved before pinning became all or nothing, in which case the + * host has not made a choice yet. + */ +export function getShouldPinPrices( + settingsPinned: HostSettingsPinned, +): boolean | null { + const pinned = [ + settingsPinned.storage.pinned, + settingsPinned.egress.pinned, + settingsPinned.ingress.pinned, + settingsPinned.maxCollateral.pinned, + ] + if (pinned.every((p) => p)) { + return true + } + if (pinned.every((p) => !p)) { + return false + } + return null +} + /** * Calculates the max collateral based on the storage price, collateral multiplier, and factor. * @param storage - The storage price. diff --git a/apps/hostd/contexts/config/types.ts b/apps/hostd/contexts/config/types.ts index 0a2292f11..e9b7687f4 100644 --- a/apps/hostd/contexts/config/types.ts +++ b/apps/hostd/contexts/config/types.ts @@ -31,16 +31,26 @@ export const dnsProviderOptions: { value: DNSProvider; label: string }[] = [ export const defaultValuesSettingsPinned = { pinnedCurrency: '' as CurrencyId | '', pinnedThreshold: new BigNumber(0), - shouldPinStoragePrice: false, + // Pinning is all or nothing - either every price is pinned to a fiat value + // or none are. Hosts configured before this was enforced may still have a + // mixed set of pinned prices, which reads as `null`: the host has not made + // an all or nothing choice yet, so their configuration is left as it is + // until they do. `null` is reserved for that confirmed mixed response - the + // default here is `false` so that pinned settings which are missing or + // errored are not mistaken for a mixed configuration. + shouldPinPrices: false as boolean | null, storagePricePinned: new BigNumber(0), - shouldPinEgressPrice: false, egressPricePinned: new BigNumber(0), - shouldPinIngressPrice: false, ingressPricePinned: new BigNumber(0), - shouldPinMaxCollateral: false, maxCollateralPinned: new BigNumber(0), } +/** + * The prices that can be pinned to a fiat value, as keyed on the daemon's + * pinned settings. + */ +export type PinnablePrice = 'storage' | 'egress' | 'ingress' | 'maxCollateral' + export const defaultValuesSettings = { // Host settings acceptingContracts: false, diff --git a/apps/hostd/contexts/config/useOnValid.tsx b/apps/hostd/contexts/config/useOnValid.tsx index 99d35db4c..de0b40d53 100644 --- a/apps/hostd/contexts/config/useOnValid.tsx +++ b/apps/hostd/contexts/config/useOnValid.tsx @@ -45,7 +45,9 @@ export function useOnValid({ throw Error(settings.error) } - if (state.data?.explorer.enabled) { + // Without the current pinned settings there is nothing to base an + // update on, and writing the form defaults would unpin every price. + if (state.data?.explorer.enabled && resources.settingsPinned.data) { const settingsPinned = await settingsPinnedUpdate.put({ payload: transformUpSettingsPinned( values, diff --git a/libs/e2e/src/fixtures/textInput.ts b/libs/e2e/src/fixtures/textInput.ts index 4361b28db..d86d9ba8a 100644 --- a/libs/e2e/src/fixtures/textInput.ts +++ b/libs/e2e/src/fixtures/textInput.ts @@ -19,6 +19,13 @@ export const expectTextInputByName = step( }, ) +export const expectTextInputVisible = step( + 'expect text input visible', + async (page: Page, name: string) => { + await expect(page.locator(`input[name="${name}"]`)).toBeVisible() + }, +) + export const expectTextInputNotVisible = step( 'expect text input not visible', async (page: Page, name: string) => {