From 7a2ea3c66f8f5dc3753b0f228fb61b6c8e470b3e Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Mon, 26 Aug 2024 16:06:27 -0400 Subject: [PATCH] feat: renterd wallet send --- .changeset/moody-zebras-fly.md | 6 + .changeset/shy-dots-complain.md | 7 + .changeset/silly-insects-glow.md | 5 + .changeset/thick-rats-smell.md | 7 + .changeset/young-gifts-return.md | 7 + apps/hostd-e2e/src/fixtures/beforeTest.ts | 26 +++ apps/hostd-e2e/src/fixtures/navigate.ts | 11 +- apps/hostd-e2e/src/fixtures/preferences.ts | 11 ++ apps/hostd-e2e/src/fixtures/siascan.ts | 10 ++ apps/hostd-e2e/src/specs/config.spec.ts | 25 +-- apps/hostd-e2e/src/specs/volumes.spec.ts | 7 +- apps/hostd-e2e/src/specs/wallet.spec.ts | 156 ++++++++++++++++++ apps/hostd/dialogs/HostdSendSiacoinDialog.tsx | 49 +++++- apps/renterd-e2e/src/fixtures/beforeTest.ts | 10 +- apps/renterd-e2e/src/fixtures/navigate.ts | 5 + apps/renterd-e2e/src/specs/wallet.spec.ts | 156 ++++++++++++++++++ .../dialogs/RenterdSendSiacoinDialog.tsx | 88 ++++------ .../src/app/WalletLayoutActions.tsx | 9 +- .../app/WalletSendSiacoinDialog/Complete.tsx | 11 +- .../app/WalletSendSiacoinDialog/Confirm.tsx | 25 +-- .../app/WalletSendSiacoinDialog/Generate.tsx | 31 ++-- .../app/WalletSendSiacoinDialog/Receipt.tsx | 42 +++-- .../src/app/WalletSendSiacoinDialog/index.tsx | 23 +-- .../src/app/WalletSendSiacoinDialog/types.ts | 9 + .../src/app/WalletSendSiacoinDialog/utils.ts | 27 +++ .../src/components/ValueCopyable.tsx | 4 +- libs/design-system/src/components/ValueSc.tsx | 3 + libs/hostd-types/src/api.ts | 1 + libs/renterd-js/src/bus.ts | 25 ++- libs/renterd-react/src/bus.ts | 34 +++- libs/renterd-types/src/bus.ts | 19 ++- 31 files changed, 683 insertions(+), 166 deletions(-) create mode 100644 .changeset/moody-zebras-fly.md create mode 100644 .changeset/shy-dots-complain.md create mode 100644 .changeset/silly-insects-glow.md create mode 100644 .changeset/thick-rats-smell.md create mode 100644 .changeset/young-gifts-return.md create mode 100644 apps/hostd-e2e/src/fixtures/beforeTest.ts create mode 100644 apps/hostd-e2e/src/fixtures/preferences.ts create mode 100644 apps/hostd-e2e/src/fixtures/siascan.ts create mode 100644 apps/hostd-e2e/src/specs/wallet.spec.ts create mode 100644 apps/renterd-e2e/src/specs/wallet.spec.ts create mode 100644 libs/design-system/src/app/WalletSendSiacoinDialog/types.ts create mode 100644 libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts diff --git a/.changeset/moody-zebras-fly.md b/.changeset/moody-zebras-fly.md new file mode 100644 index 000000000..0215e5050 --- /dev/null +++ b/.changeset/moody-zebras-fly.md @@ -0,0 +1,6 @@ +--- +'hostd': minor +'renterd': minor +--- + +The send siacoin feature now calculates the fee using the daemon's recommended fee per byte and a standard transaction size. diff --git a/.changeset/shy-dots-complain.md b/.changeset/shy-dots-complain.md new file mode 100644 index 000000000..63211b1b1 --- /dev/null +++ b/.changeset/shy-dots-complain.md @@ -0,0 +1,7 @@ +--- +'@siafoundation/hostd-types': minor +'@siafoundation/hostd-js': minor +'@siafoundation/hostd-react': minor +--- + +The wallet send API now supports the subtractMinerFee option. diff --git a/.changeset/silly-insects-glow.md b/.changeset/silly-insects-glow.md new file mode 100644 index 000000000..c698c1a98 --- /dev/null +++ b/.changeset/silly-insects-glow.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +- The send siacoin feature will now use unconfirmed outputs if necessary. diff --git a/.changeset/thick-rats-smell.md b/.changeset/thick-rats-smell.md new file mode 100644 index 000000000..659cb0b1e --- /dev/null +++ b/.changeset/thick-rats-smell.md @@ -0,0 +1,7 @@ +--- +'@siafoundation/renterd-js': patch +'@siafoundation/renterd-react': patch +'@siafoundation/renterd-types': patch +--- + +Fixed the route for the recommended fee API. diff --git a/.changeset/young-gifts-return.md b/.changeset/young-gifts-return.md new file mode 100644 index 000000000..30d2ccf8b --- /dev/null +++ b/.changeset/young-gifts-return.md @@ -0,0 +1,7 @@ +--- +'@siafoundation/renterd-js': minor +'@siafoundation/renterd-react': minor +'@siafoundation/renterd-types': minor +--- + +Added the wallet send API. diff --git a/apps/hostd-e2e/src/fixtures/beforeTest.ts b/apps/hostd-e2e/src/fixtures/beforeTest.ts new file mode 100644 index 000000000..ba4ee48c8 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/beforeTest.ts @@ -0,0 +1,26 @@ +import { + mockApiSiaCentralExchangeRates, + mockApiSiaCentralHostsNetworkAverages, +} from '@siafoundation/sia-central-mock' +import { login } from './login' +import { navigateToConfig } from './navigate' +import { configResetAllSettings } from './configResetAllSettings' +import { setViewMode } from './configViewMode' +import { Page } from 'playwright' +import { mockApiSiaScanExchangeRates } from './siascan' +import { setCurrencyDisplay } from './preferences' + +export async function beforeTest(page: Page, shouldResetConfig = true) { + await mockApiSiaCentralExchangeRates({ page }) + await mockApiSiaCentralHostsNetworkAverages({ page }) + await mockApiSiaScanExchangeRates({ page }) + await login({ page }) + + // Reset state. + await setCurrencyDisplay(page, 'bothPreferSc') + if (shouldResetConfig) { + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + } +} diff --git a/apps/hostd-e2e/src/fixtures/navigate.ts b/apps/hostd-e2e/src/fixtures/navigate.ts index bc8e4de0c..e954fddfa 100644 --- a/apps/hostd-e2e/src/fixtures/navigate.ts +++ b/apps/hostd-e2e/src/fixtures/navigate.ts @@ -1,18 +1,23 @@ import { Page, expect } from '@playwright/test' export async function navigateToDashboard({ page }: { page: Page }) { - await page.getByLabel('Overview').click() + await page.getByTestId('sidenav').getByLabel('Overview').click() await expect(page.getByTestId('navbar').getByText('Overview')).toBeVisible() } export async function navigateToConfig({ page }: { page: Page }) { - await page.getByLabel('Configuration').click() + await page.getByTestId('sidenav').getByLabel('Configuration').click() await expect( page.getByTestId('navbar').getByText('Configuration') ).toBeVisible() } export async function navigateToVolumes({ page }: { page: Page }) { - await page.getByLabel('Volumes').click() + await page.getByTestId('sidenav').getByLabel('Volumes').click() await expect(page.getByTestId('navbar').getByText('Volumes')).toBeVisible() } + +export async function navigateToWallet(page: Page) { + await page.getByTestId('sidenav').getByLabel('Wallet').click() + await expect(page.getByTestId('navbar').getByText('Wallet')).toBeVisible() +} diff --git a/apps/hostd-e2e/src/fixtures/preferences.ts b/apps/hostd-e2e/src/fixtures/preferences.ts new file mode 100644 index 000000000..8bd7dbcaf --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/preferences.ts @@ -0,0 +1,11 @@ +import { Page } from 'playwright' +import { fillSelectInputByName } from './selectInput' + +export async function setCurrencyDisplay( + page: Page, + display: 'sc' | 'fiat' | 'bothPreferSc' | 'bothPreferFiat' +) { + await page.getByLabel('App preferences').click() + await fillSelectInputByName(page, 'currencyDisplay', display) + await page.getByRole('dialog').getByLabel('close').click() +} diff --git a/apps/hostd-e2e/src/fixtures/siascan.ts b/apps/hostd-e2e/src/fixtures/siascan.ts new file mode 100644 index 000000000..3f3349161 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/siascan.ts @@ -0,0 +1,10 @@ +import { Page } from 'playwright' + +export async function mockApiSiaScanExchangeRates({ page }: { page: Page }) { + await page.route( + 'https://api.siascan.com/exchange-rate/siacoin/*', + async (route) => { + await route.fulfill({ json: 0.003944045283 }) + } + ) +} diff --git a/apps/hostd-e2e/src/specs/config.spec.ts b/apps/hostd-e2e/src/specs/config.spec.ts index 2f1821411..0f41e94e7 100644 --- a/apps/hostd-e2e/src/specs/config.spec.ts +++ b/apps/hostd-e2e/src/specs/config.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test' -import { login } from '../fixtures/login' import { expectSwitchByLabel, expectSwitchVisible, @@ -7,23 +6,20 @@ import { } from '../fixtures/switchValue' import { setViewMode } from '../fixtures/configViewMode' import { navigateToConfig } from '../fixtures/navigate' -import { mockApiSiaCentralExchangeRates } from '@siafoundation/sia-central-mock' -import { configResetAllSettings } from '../fixtures/configResetAllSettings' import { expectTextInputByName, expectTextInputNotVisible, fillTextInputByName, } from '../fixtures/textInput' import { fillSelectInputByName } from '../fixtures/selectInput' +import { beforeTest } from '../fixtures/beforeTest' -test('basic field change and save behaviour', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) +test.beforeEach(async ({ page }) => { + await beforeTest(page) +}) +test('basic field change and save behaviour', async ({ page }) => { // Reset state. - await navigateToConfig({ page }) - await configResetAllSettings({ page }) await setViewMode({ page, state: 'advanced' }) // Test that values can be updated. @@ -63,10 +59,6 @@ test('basic field change and save behaviour', async ({ page }) => { }) test('pin switches should show in both view modes', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - await navigateToConfig({ page }) await setViewMode({ page, state: 'basic' }) await expectSwitchVisible(page, 'shouldPinStoragePrice') @@ -83,14 +75,7 @@ test('pin switches should show in both view modes', async ({ page }) => { }) test('dynamic max collateral suggestion', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - - // Reset state. await navigateToConfig({ page }) - await configResetAllSettings({ page }) - await setViewMode({ page, state: 'basic' }) await fillTextInputByName(page, 'maxCollateral', '777') await expect( page diff --git a/apps/hostd-e2e/src/specs/volumes.spec.ts b/apps/hostd-e2e/src/specs/volumes.spec.ts index 05155aeb6..7814306d4 100644 --- a/apps/hostd-e2e/src/specs/volumes.spec.ts +++ b/apps/hostd-e2e/src/specs/volumes.spec.ts @@ -1,16 +1,19 @@ import { test } from '@playwright/test' import { navigateToVolumes } from '../fixtures/navigate' -import { login } from '../fixtures/login' import { createVolume, deleteVolume, deleteVolumeIfExists, } from '../fixtures/volumes' +import { beforeTest } from '../fixtures/beforeTest' + +test.beforeEach(async ({ page }) => { + await beforeTest(page, false) +}) test('can create and delete a volume', async ({ page }) => { const name = 'my-new-volume' const path = '/data' - await login({ page }) await navigateToVolumes({ page }) await deleteVolumeIfExists(page, name, path) await createVolume(page, name, path) diff --git a/apps/hostd-e2e/src/specs/wallet.spec.ts b/apps/hostd-e2e/src/specs/wallet.spec.ts new file mode 100644 index 000000000..e4ef5e68b --- /dev/null +++ b/apps/hostd-e2e/src/specs/wallet.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '@playwright/test' +import { navigateToWallet } from '../fixtures/navigate' +import { random } from '@technically/lodash' +import { beforeTest } from '../fixtures/beforeTest' +import { fillTextInputByName } from '../fixtures/textInput' +import { setSwitchByLabel } from '../fixtures/switchValue' +import BigNumber from 'bignumber.js' + +test.beforeEach(async ({ page }) => { + await beforeTest(page, false) +}) + +test('send siacoin with include fee off', async ({ page }) => { + const receiveAddress = + '5739945c21e60afd70eaf97ccd33ea27836e0219212449f39e4b38acaa8b3119aa4150a9ef0f' + const amount = String(random(1, 5)) + const amountString = `${amount}.000 SC` + const amountWithFeeString = `${amount}.012 SC` + + await navigateToWallet(page) + + // Setup. + await page.getByLabel('send').click() + const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) + await fillTextInputByName(page, 'address', receiveAddress) + await fillTextInputByName(page, 'siacoin', amount) + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + + // Confirm. + await page.getByRole('button', { name: 'Generate transaction' }).click() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + + // Complete. + await page.getByRole('button', { name: 'Broadcast transaction' }).click() + await expect( + page.getByText('Transaction successfully broadcasted.') + ).toBeVisible() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + await expect(sendDialog.getByTestId('transactionId')).toBeVisible() + + // List. + // TODO: Add this after we migrate to the new events API. + // await sendDialog.getByRole('button', { name: 'Close' }).click() + // await expect(page.getByTestId('eventsTable')).toBeVisible() + // await expect( + // page.getByTestId('eventsTable').locator('tbody tr').first() + // ).toBeVisible() + // await expect( + // page + // .getByTestId('eventsTable') + // .locator('tbody tr') + // .first() + // .getByTestId('amount') + // .getByText(`-${amountWithFeeString}`) + // ).toBeVisible() +}) + +test('send siacoin with include fee on', async ({ page }) => { + const receiveAddress = + '5739945c21e60afd70eaf97ccd33ea27836e0219212449f39e4b38acaa8b3119aa4150a9ef0f' + const amount = new BigNumber(random(1, 5)) + const amountString = `${amount.toFixed(3)} SC` + const amountWithoutFeeString = `${amount.minus(0.012).toFixed(3)} SC` + + await navigateToWallet(page) + + // Setup. + await page.getByLabel('send').click() + const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) + await fillTextInputByName(page, 'address', receiveAddress) + await fillTextInputByName(page, 'siacoin', amount.toString()) + await setSwitchByLabel(page, 'include fee', true) + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + + // Confirm. + await page.getByRole('button', { name: 'Generate transaction' }).click() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountWithoutFeeString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + + // Complete. + await page.getByRole('button', { name: 'Broadcast transaction' }).click() + await expect( + page.getByText('Transaction successfully broadcasted.') + ).toBeVisible() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountWithoutFeeString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + await expect(sendDialog.getByTestId('transactionId')).toBeVisible() + + // List. + // TODO: Add this after we migrate to the new events API. + // await sendDialog.getByRole('button', { name: 'Close' }).click() + // await expect(page.getByTestId('eventsTable')).toBeVisible() + // await expect( + // page.getByTestId('eventsTable').locator('tbody tr').first() + // ).toBeVisible() + // await expect( + // page + // .getByTestId('eventsTable') + // .locator('tbody tr') + // .first() + // .getByTestId('amount') + // .getByText(`-${amountWithFeeString}`) + // ).toBeVisible() +}) diff --git a/apps/hostd/dialogs/HostdSendSiacoinDialog.tsx b/apps/hostd/dialogs/HostdSendSiacoinDialog.tsx index 7214b2b62..2f34b2d29 100644 --- a/apps/hostd/dialogs/HostdSendSiacoinDialog.tsx +++ b/apps/hostd/dialogs/HostdSendSiacoinDialog.tsx @@ -1,28 +1,60 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { WalletSendSiacoinDialog } from '@siafoundation/design-system' -import { useWallet, useWalletSend } from '@siafoundation/hostd-react' +import { + useTxPoolFee, + useWallet, + useWalletSend, +} from '@siafoundation/hostd-react' import { useDialog } from '../contexts/dialog' import BigNumber from 'bignumber.js' +const standardTxnSize = 1200 // bytes + export function HostdSendSiacoinDialog() { const { dialog, openDialog, closeDialog } = useDialog() const wallet = useWallet() const walletSend = useWalletSend() + const recommendedFee = useTxPoolFee() + const fee = useMemo( + () => + recommendedFee.data + ? // This is the same estimated fee calculation that happens in the daemon. + new BigNumber( + typeof recommendedFee.data === 'string' + ? recommendedFee.data + : // @ts-expect-error: TODO: Fix this. + recommendedFee.data.Lo + ).times(standardTxnSize) + : undefined, + [recommendedFee.data] + ) + const send = useCallback( - async ({ sc, address }: { sc: BigNumber; address: string }) => { - const fundResponse = await walletSend.post({ + async ({ + address, + hastings, + includeFee, + }: { + address: string + hastings: BigNumber + includeFee: boolean + }) => { + const response = await walletSend.post({ payload: { address, - amount: sc.toString(), + amount: hastings.toString(), + subtractMinerFee: includeFee, }, }) - if (fundResponse.error) { + if (response.error) { return { - error: fundResponse.error, + error: response.error, } } - return { transactionId: fundResponse.data } + return { + transactionId: response.data, + } }, [walletSend] ) @@ -31,6 +63,7 @@ export function HostdSendSiacoinDialog() { (val ? openDialog(dialog) : closeDialog())} /> diff --git a/apps/renterd-e2e/src/fixtures/beforeTest.ts b/apps/renterd-e2e/src/fixtures/beforeTest.ts index 19622fe5c..ba4ee48c8 100644 --- a/apps/renterd-e2e/src/fixtures/beforeTest.ts +++ b/apps/renterd-e2e/src/fixtures/beforeTest.ts @@ -10,7 +10,7 @@ import { Page } from 'playwright' import { mockApiSiaScanExchangeRates } from './siascan' import { setCurrencyDisplay } from './preferences' -export async function beforeTest(page: Page) { +export async function beforeTest(page: Page, shouldResetConfig = true) { await mockApiSiaCentralExchangeRates({ page }) await mockApiSiaCentralHostsNetworkAverages({ page }) await mockApiSiaScanExchangeRates({ page }) @@ -18,7 +18,9 @@ export async function beforeTest(page: Page) { // Reset state. await setCurrencyDisplay(page, 'bothPreferSc') - await navigateToConfig({ page }) - await configResetAllSettings({ page }) - await setViewMode({ page, state: 'basic' }) + if (shouldResetConfig) { + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + } } diff --git a/apps/renterd-e2e/src/fixtures/navigate.ts b/apps/renterd-e2e/src/fixtures/navigate.ts index b3bb28812..640a15c56 100644 --- a/apps/renterd-e2e/src/fixtures/navigate.ts +++ b/apps/renterd-e2e/src/fixtures/navigate.ts @@ -18,3 +18,8 @@ export async function navigateToConfig({ page }: { page: Page }) { page.getByTestId('navbar').getByText('Configuration') ).toBeVisible() } + +export async function navigateToWallet(page: Page) { + await page.getByTestId('sidenav').getByLabel('Wallet').click() + await expect(page.getByTestId('navbar').getByText('Wallet')).toBeVisible() +} diff --git a/apps/renterd-e2e/src/specs/wallet.spec.ts b/apps/renterd-e2e/src/specs/wallet.spec.ts new file mode 100644 index 000000000..47891dc23 --- /dev/null +++ b/apps/renterd-e2e/src/specs/wallet.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '@playwright/test' +import { navigateToWallet } from '../fixtures/navigate' +import { random } from '@technically/lodash' +import { beforeTest } from '../fixtures/beforeTest' +import { fillTextInputByName } from '../fixtures/textInput' +import BigNumber from 'bignumber.js' +import { setSwitchByLabel } from '../fixtures/switchValue' + +test.beforeEach(async ({ page }) => { + await beforeTest(page, false) +}) + +test('send siacoin with include fee off', async ({ page }) => { + const receiveAddress = + '5739945c21e60afd70eaf97ccd33ea27836e0219212449f39e4b38acaa8b3119aa4150a9ef0f' + const amount = String(random(1, 5)) + const amountString = `${amount}.000 SC` + const amountWithFeeString = `${amount}.012 SC` + + await navigateToWallet(page) + + // Setup. + await page.getByLabel('send').click() + const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) + await fillTextInputByName(page, 'address', receiveAddress) + await fillTextInputByName(page, 'siacoin', amount) + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + + // Confirm. + await page.getByRole('button', { name: 'Generate transaction' }).click() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + + // Complete. + await page.getByRole('button', { name: 'Broadcast transaction' }).click() + await expect( + page.getByText('Transaction successfully broadcasted.') + ).toBeVisible() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountWithFeeString) + ).toBeVisible() + await expect(sendDialog.getByTestId('transactionId')).toBeVisible() + + // List. + // TODO: Add this after we migrate to the new events API. + // await sendDialog.getByRole('button', { name: 'Close' }).click() + // await expect(page.getByTestId('eventsTable')).toBeVisible() + // await expect( + // page.getByTestId('eventsTable').locator('tbody tr').first() + // ).toBeVisible() + // await expect( + // page + // .getByTestId('eventsTable') + // .locator('tbody tr') + // .first() + // .getByTestId('amount') + // .getByText(`-${amountWithFeeString}`) + // ).toBeVisible() +}) + +test('send siacoin with include fee on', async ({ page }) => { + const receiveAddress = + '5739945c21e60afd70eaf97ccd33ea27836e0219212449f39e4b38acaa8b3119aa4150a9ef0f' + const amount = new BigNumber(random(1, 5)) + const amountString = `${amount.toFixed(3)} SC` + const amountWithoutFeeString = `${amount.minus(0.012).toFixed(3)} SC` + + await navigateToWallet(page) + + // Setup. + await page.getByLabel('send').click() + const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) + await fillTextInputByName(page, 'address', receiveAddress) + await fillTextInputByName(page, 'siacoin', amount.toString()) + await setSwitchByLabel(page, 'include fee', true) + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + + // Confirm. + await page.getByRole('button', { name: 'Generate transaction' }).click() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountWithoutFeeString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + + // Complete. + await page.getByRole('button', { name: 'Broadcast transaction' }).click() + await expect( + page.getByText('Transaction successfully broadcasted.') + ).toBeVisible() + await expect( + sendDialog.getByTestId('address').getByText(receiveAddress.slice(0, 5)) + ).toBeVisible() + await expect( + sendDialog.getByTestId('amount').getByText(amountWithoutFeeString) + ).toBeVisible() + await expect( + sendDialog.getByTestId('networkFee').getByText('0.012 SC') + ).toBeVisible() + await expect( + sendDialog.getByTestId('total').getByText(amountString) + ).toBeVisible() + await expect(sendDialog.getByTestId('transactionId')).toBeVisible() + + // List. + // TODO: Add this after we migrate to the new events API. + // await sendDialog.getByRole('button', { name: 'Close' }).click() + // await expect(page.getByTestId('eventsTable')).toBeVisible() + // await expect( + // page.getByTestId('eventsTable').locator('tbody tr').first() + // ).toBeVisible() + // await expect( + // page + // .getByTestId('eventsTable') + // .locator('tbody tr') + // .first() + // .getByTestId('amount') + // .getByText(`-${amountWithFeeString}`) + // ).toBeVisible() +}) diff --git a/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx b/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx index 56c9ce7b8..2ad71f355 100644 --- a/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx +++ b/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx @@ -1,83 +1,65 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { WalletSendSiacoinDialog } from '@siafoundation/design-system' import { - useTxPoolBroadcast, + useTxPoolRecommendedFee, useWallet, - useWalletDiscard, - useWalletFund, - useWalletSign, + useWalletSend, } from '@siafoundation/renterd-react' import { useDialog } from '../contexts/dialog' import BigNumber from 'bignumber.js' +const standardTxnSize = 1200 // bytes + export function RenterdSendSiacoinDialog() { const { dialog, openDialog, closeDialog } = useDialog() const wallet = useWallet() - const fund = useWalletFund() - const sign = useWalletSign() - const broadcast = useTxPoolBroadcast() - const discard = useWalletDiscard() + const recommendedFee = useTxPoolRecommendedFee() + const fee = useMemo( + () => + recommendedFee.data + ? // This is the same estimated fee calculation that happens in the daemon. + new BigNumber(recommendedFee.data).times(standardTxnSize) + : undefined, + [recommendedFee.data] + ) + + const walletSend = useWalletSend() const send = useCallback( - async ({ sc, address }: { sc: BigNumber; address: string }) => { - const fundResponse = await fund.post({ + async ({ + address, + hastings, + includeFee, + }: { + address: string + hastings: BigNumber + includeFee: boolean + }) => { + const response = await walletSend.post({ payload: { - amount: sc.toString(), - transaction: { - siacoinOutputs: [ - { - address: address, - value: sc.toString(), - }, - ], - }, + address, + amount: hastings.toString(), + subtractMinerFee: includeFee, + useUnconfirmed: true, }, }) - if (fundResponse.error) { - return { - error: fundResponse.error, - } - } - const signResponse = await sign.post({ - payload: { - transaction: fundResponse.data.transaction, - toSign: fundResponse.data.toSign, - coveredFields: { - wholeTransaction: true, - }, - }, - }) - if (signResponse.error) { - discard.post({ - payload: fundResponse.data.transaction, - }) - return { - error: signResponse.error, - } - } - const broadcastResponse = await broadcast.post({ - payload: [signResponse.data], - }) - if (broadcastResponse.error) { - discard.post({ - payload: signResponse.data, - }) + if (response.error) { return { - error: broadcastResponse.error, + error: response.error, } } return { - // Need transaction ID, but its not part of transaction object - // transactionId: signResponse.data.??, + transactionId: response.data, } }, - [fund, sign, broadcast, discard] + [walletSend] ) return ( (val ? openDialog(dialog) : closeDialog())} /> diff --git a/libs/design-system/src/app/WalletLayoutActions.tsx b/libs/design-system/src/app/WalletLayoutActions.tsx index 83adeffd5..18aad485f 100644 --- a/libs/design-system/src/app/WalletLayoutActions.tsx +++ b/libs/design-system/src/app/WalletLayoutActions.tsx @@ -41,12 +41,17 @@ export function WalletLayoutActions({ /> )} {receiveSiacoin && ( - )} - diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx index cbe53362f..208c320a6 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx @@ -2,18 +2,16 @@ import BigNumber from 'bignumber.js' import { Text } from '../../core/Text' import { CheckmarkFilled32 } from '@siafoundation/react-icons' import { WalletSendSiacoinReceipt } from './Receipt' +import { SendSiacoinFormData } from './types' type Props = { - data: { - address: string - siacoin: BigNumber - } + data: SendSiacoinFormData fee: BigNumber transactionId?: string } export function WalletSendSiacoinComplete({ - data: { address, siacoin }, + data: { address, hastings, includeFee }, fee, transactionId, }: Props) { @@ -21,7 +19,8 @@ export function WalletSendSiacoinComplete({
diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx index a7e0631b0..4e4a2499a 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx @@ -1,16 +1,13 @@ import { useFormik } from 'formik' import BigNumber from 'bignumber.js' import { WalletSendSiacoinReceipt } from './Receipt' +import { SendSiacoinFormData } from './types' type Props = { - send: (params: { - address: string - sc: BigNumber - }) => Promise<{ transactionId?: string; error?: string }> - formData: { - address: string - siacoin: BigNumber - } + send: ( + params: SendSiacoinFormData + ) => Promise<{ transactionId?: string; error?: string }> + formData: SendSiacoinFormData fee: BigNumber onConfirm: (params: { transactionId?: string }) => void } @@ -21,13 +18,14 @@ export function useSendSiacoinConfirmForm({ fee, onConfirm, }: Props) { - const { address, siacoin } = formData || {} + const { address, hastings, includeFee } = formData || {} const formik = useFormik({ initialValues: {}, onSubmit: async () => { const { transactionId, error } = await send({ address, - sc: siacoin, + hastings, + includeFee, }) if (error) { @@ -45,7 +43,12 @@ export function useSendSiacoinConfirmForm({ const form = (
- +
) diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx index 1709e2c51..89c6f55b0 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx @@ -7,6 +7,8 @@ import { InfoTip } from '../../core/InfoTip' import { Switch } from '../../core/Switch' import { ValueSc } from '../../components/ValueSc' import { FormFieldFormik } from '../../components/FormFormik' +import { SendSiacoinFormData } from './types' +import { getTotalTransactionCost } from './utils' const exampleAddr = 'e3b1050aef388438668b52983cf78f40925af8f0aa8b9de80c18eadcefce8388d168a313e3f2' @@ -28,15 +30,9 @@ const validationSchema = Yup.object().shape({ ), }) -type FormData = { - address: string - siacoin: BigNumber - includeFee: boolean -} - type Props = { fee: BigNumber - onComplete: (data: FormData) => void + onComplete: (data: SendSiacoinFormData) => void balance?: BigNumber } @@ -49,12 +45,13 @@ export function useSendSiacoinGenerateForm({ initialValues, validationSchema, onSubmit: async (values) => { + if (!fee) { + return + } + if (!values.siacoin) { return } - const siacoin = values.includeFee - ? toHastings(values.siacoin).minus(fee) - : toHastings(values.siacoin) if (!balance) { return @@ -69,12 +66,12 @@ export function useSendSiacoinGenerateForm({ onComplete({ includeFee: values.includeFee, address: values.address, - siacoin: siacoin, + hastings: toHastings(values.siacoin), }) }, }) - const sc = toHastings(formik.values.siacoin || 0) + const hastings = toHastings(formik.values.siacoin || 0) const form = (
@@ -96,7 +93,9 @@ export function useSendSiacoinGenerateForm({ />
formik.setFieldValue('includeFee', val)} > Include fee @@ -111,6 +110,7 @@ export function useSendSiacoinGenerateForm({ Network fee
Total
diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx index 5b67929da..cb59d4e4a 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx @@ -2,28 +2,38 @@ import { Text } from '../../core/Text' import { ValueSc } from '../../components/ValueSc' import BigNumber from 'bignumber.js' import { ValueCopyable } from '../../components/ValueCopyable' +import { SendSiacoinFormData } from './types' +import { getAmountUserWillReceive, getTotalTransactionCost } from './utils' -type Props = { - address: string - siacoin: BigNumber +type Props = SendSiacoinFormData & { fee: BigNumber transactionId?: string } export function WalletSendSiacoinReceipt({ address, - siacoin, + hastings, fee, + includeFee, transactionId, }: Props) { - const totalSiacoin = siacoin.plus(fee) + const amount = getAmountUserWillReceive({ + hastings, + includeFee, + fee, + }) + const total = getTotalTransactionCost({ + hastings, + includeFee, + fee, + }) return (
Address - +
@@ -31,8 +41,9 @@ export function WalletSendSiacoinReceipt({
@@ -43,7 +54,13 @@ export function WalletSendSiacoinReceipt({ Network fee
- +
@@ -52,8 +69,9 @@ export function WalletSendSiacoinReceipt({
@@ -64,7 +82,11 @@ export function WalletSendSiacoinReceipt({ Transaction ID - +
)}
diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx index 425dcdcb1..57824a993 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx @@ -2,7 +2,6 @@ import BigNumber from 'bignumber.js' import { useMemo, useState } from 'react' -import { toHastings } from '@siafoundation/units' import { Separator } from '../../core/Separator' import { Dialog } from '../../core/Dialog' import { useSendSiacoinGenerateForm } from './Generate' @@ -10,20 +9,13 @@ import { useSendSiacoinConfirmForm } from './Confirm' import { ProgressSteps } from '../ProgressSteps' import { WalletSendSiacoinComplete } from './Complete' import { FormSubmitButtonFormik } from '../../components/FormFormik' - -export type SendSiacoinFormData = { - address: string - siacoin: BigNumber - includeFee: boolean -} +import { SendSiacoinFormData } from './types' type Step = 'setup' | 'confirm' | 'done' -const fee = toHastings(0.00393) - -const emptyFormData = { +const emptyFormData: SendSiacoinFormData = { address: '', - siacoin: new BigNumber(0), + hastings: new BigNumber(0), includeFee: false, } @@ -32,10 +24,10 @@ type Props = { open: boolean onOpenChange: (val: boolean) => void balance?: BigNumber - send: (params: { - address: string - sc: BigNumber - }) => Promise<{ transactionId?: string; error?: string }> + fee: BigNumber + send: ( + params: SendSiacoinFormData + ) => Promise<{ transactionId?: string; error?: string }> } export function WalletSendSiacoinDialog({ @@ -43,6 +35,7 @@ export function WalletSendSiacoinDialog({ open, onOpenChange, balance, + fee, send, }: Props) { const [step, setStep] = useState('setup') diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts b/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts new file mode 100644 index 000000000..872776498 --- /dev/null +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts @@ -0,0 +1,9 @@ +'use client' + +import BigNumber from 'bignumber.js' + +export type SendSiacoinFormData = { + address: string + hastings: BigNumber + includeFee: boolean +} diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts b/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts new file mode 100644 index 000000000..e529d4eed --- /dev/null +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts @@ -0,0 +1,27 @@ +'use client' + +import BigNumber from 'bignumber.js' + +export function getTotalTransactionCost({ + hastings, + includeFee, + fee, +}: { + hastings: BigNumber + includeFee: boolean + fee: BigNumber +}) { + return includeFee ? hastings : hastings.plus(fee) +} + +export function getAmountUserWillReceive({ + hastings, + includeFee, + fee, +}: { + hastings: BigNumber + includeFee: boolean + fee: BigNumber +}) { + return includeFee ? hastings.minus(fee) : hastings +} diff --git a/libs/design-system/src/components/ValueCopyable.tsx b/libs/design-system/src/components/ValueCopyable.tsx index db5da2715..210364efe 100644 --- a/libs/design-system/src/components/ValueCopyable.tsx +++ b/libs/design-system/src/components/ValueCopyable.tsx @@ -23,6 +23,7 @@ import { } from '../core/DropdownMenu' type Props = { + testId?: string value: string displayValue?: string type?: EntityType @@ -40,6 +41,7 @@ type Props = { } export function ValueCopyable({ + testId, value, displayValue, type, @@ -65,7 +67,7 @@ export function ValueCopyable({ const text = renderValue || defaultFormatValue(cleanValue, maxLength) return ( -
+
{href ? ( ['size'] scaleSize?: React.ComponentProps['scaleSize'] value: BigNumber // hastings @@ -22,6 +23,7 @@ type Props = { } export function ValueSc({ + testId, value, size, scaleSize, @@ -49,6 +51,7 @@ export function ValueSc({ const el = ( (axios, 'get', busSyncerAddrRoute), txPoolFee: buildRequestHandler< - TxPoolFeeParams, - TxPoolFeePayload, - TxPoolFeeResponse - >(axios, 'get', busTxpoolFeeRoute), + TxPoolRecommendedFeeParams, + TxPoolRecommendedFeePayload, + TxPoolRecommendedFeeResponse + >(axios, 'get', busTxpoolRecommendedFeeRoute), txPoolTransactions: buildRequestHandler< TxPoolTransactionsParams, TxPoolTransactionsPayload, @@ -353,6 +357,11 @@ export function Bus({ api, password }: { api: string; password?: string }) { WalletFundPayload, WalletFundResponse >(axios, 'post', busWalletFundRoute), + walletSend: buildRequestHandler< + WalletSendParams, + WalletSendPayload, + WalletSendResponse + >(axios, 'post', busWalletSendRoute), walletSign: buildRequestHandler< WalletSignParams, WalletSignPayload, diff --git a/libs/renterd-react/src/bus.ts b/libs/renterd-react/src/bus.ts index afb30efbe..b2db1a1d4 100644 --- a/libs/renterd-react/src/bus.ts +++ b/libs/renterd-react/src/bus.ts @@ -153,8 +153,8 @@ import { TxPoolBroadcastParams, TxPoolBroadcastPayload, TxPoolBroadcastResponse, - TxPoolFeeParams, - TxPoolFeeResponse, + TxPoolRecommendedFeeParams, + TxPoolRecommendedFeeResponse, TxPoolTransactionsParams, TxPoolTransactionsResponse, WalletAddressesParams, @@ -223,7 +223,7 @@ import { busSyncerConnectRoute, busSyncerPeersRoute, busTxpoolBroadcastRoute, - busTxpoolFeeRoute, + busTxpoolRecommendedFeeRoute, busTxpoolTransactionsRoute, busWalletAddressesRoute, busWalletDiscardRoute, @@ -262,6 +262,10 @@ import { ContractSizeResponse, busContractIdSize, PricePinSettings, + WalletSendParams, + WalletSendPayload, + WalletSendResponse, + busWalletSendRoute, } from '@siafoundation/renterd-types' // state @@ -357,10 +361,10 @@ export function useSyncerAddress( // txpool -export function useTxPoolFee( - args?: HookArgsSwr +export function useTxPoolRecommendedFee( + args?: HookArgsSwr ) { - return useGetSwr({ ...args, route: busTxpoolFeeRoute }) + return useGetSwr({ ...args, route: busTxpoolRecommendedFeeRoute }) } export function useTxPoolTransactions( @@ -440,6 +444,24 @@ export function useWalletSign( return usePostFunc({ ...args, route: busWalletSignRoute }) } +export function useWalletSend( + args?: HookArgsCallback< + WalletSendParams, + WalletSendPayload, + WalletSendResponse + > +) { + return usePostFunc({ ...args, route: busWalletSendRoute }, async (mutate) => { + await delay(2_000) + mutate((key) => { + return ( + key.startsWith(busTxpoolTransactionsRoute) || + key.startsWith(busWalletPendingRoute) + ) + }) + }) +} + export function useWalletRedistribute( args?: HookArgsCallback< WalletRedistributeParams, diff --git a/libs/renterd-types/src/bus.ts b/libs/renterd-types/src/bus.ts index 6be60cb47..731143ea4 100644 --- a/libs/renterd-types/src/bus.ts +++ b/libs/renterd-types/src/bus.ts @@ -7,6 +7,7 @@ import { CoveredFields, FileContractID, Block, + TransactionID, } from '@siafoundation/types' import { ConsensusState, @@ -29,13 +30,14 @@ export const busSyncerConnectRoute = '/bus/syncer/connect' export const busSyncerAddrRoute = '/bus/syncer/addr' export const busTxpoolTransactionsRoute = '/bus/txpool/transactions' export const busTxpoolBroadcastRoute = '/bus/txpool/broadcast' -export const busTxpoolFeeRoute = '/bus/txpool/fee' +export const busTxpoolRecommendedFeeRoute = '/bus/txpool/recommendedfee' export const busWalletRoute = '/bus/wallet' export const busWalletAddressesRoute = '/bus/wallet/addresses' export const busWalletTransactionsRoute = '/bus/wallet/transactions' export const busWalletOutputsRoute = '/bus/wallet/outputs' export const busWalletFundRoute = '/bus/wallet/fund' export const busWalletSignRoute = '/bus/wallet/sign' +export const busWalletSendRoute = '/bus/wallet/send' export const busWalletRedistributeRoute = '/bus/wallet/redistribute' export const busWalletDiscardRoute = '/bus/wallet/discard' export const busWalletPrepareFormRoute = '/bus/wallet/prepare/form' @@ -131,9 +133,9 @@ export type SyncerAddressResponse = string // txpool -export type TxPoolFeeParams = void -export type TxPoolFeePayload = void -export type TxPoolFeeResponse = Currency +export type TxPoolRecommendedFeeParams = void +export type TxPoolRecommendedFeePayload = void +export type TxPoolRecommendedFeeResponse = Currency export type TxPoolTransactionsParams = void export type TxPoolTransactionsPayload = void @@ -189,6 +191,15 @@ export type WalletSignPayload = { } export type WalletSignResponse = Transaction +export type WalletSendParams = void +export type WalletSendPayload = { + address: string + amount: Currency + subtractMinerFee?: boolean + useUnconfirmed?: boolean +} +export type WalletSendResponse = TransactionID + export type WalletRedistributeParams = void export type WalletRedistributePayload = { amount: Currency