diff --git a/.nsprc b/.nsprc index 9e26dfeeb..ea8d10a0e 100644 --- a/.nsprc +++ b/.nsprc @@ -1 +1,7 @@ -{} \ No newline at end of file +{ + "1101163": { + "active": true, + "notes": "wait for new release of postcss", + "expiry": "2025-01-01" + } +} diff --git a/src/features/applications/components/application-method-definitions.test.tsx b/src/features/applications/components/application-method-definitions.test.tsx index 3653b6ec6..53706beee 100644 --- a/src/features/applications/components/application-method-definitions.test.tsx +++ b/src/features/applications/components/application-method-definitions.test.tsx @@ -15,7 +15,7 @@ import { executeComponentTest } from '@/tests/test-component' import { useParams } from 'react-router-dom' import { fireEvent, getByText, render, RenderResult, waitFor, within } from '@/tests/testing-library' import { UserEvent } from '@testing-library/user-event' -import { sendButtonLabel } from '@/features/transaction-wizard/components/transactions-builder' +import { addTransactionLabel, sendButtonLabel } from '@/features/transaction-wizard/components/transactions-builder' import { algo } from '@algorandfoundation/algokit-utils' import { transactionActionsLabel, transactionGroupTableLabel } from '@/features/transaction-wizard/components/labels' import { selectOption } from '@/tests/utils/select-option' @@ -253,6 +253,156 @@ describe('application-method-definitions', () => { } ) }) + it('fee can be set to zero and the transaction should fail', async () => { + const myStore = getTestStore() + vi.mocked(useParams).mockImplementation(() => ({ applicationId: appId.toString() })) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component, user) => { + const addMethodPanel = await expandMethodAccordion(component, user, 'add') + + // Open the build transaction dialog + const callButton = await waitFor(() => { + const addTransactionButton = within(addMethodPanel).getByRole('button', { name: 'Call' }) + expect(addTransactionButton).not.toBeDisabled() + return addTransactionButton! + }) + await user.click(callButton) + + // Fill the form + const formDialog = component.getByRole('dialog') + + const arg1Input = await getArgInput(formDialog, 'Argument 1') + fireEvent.input(arg1Input, { + target: { value: '1' }, + }) + + const arg2Input = await getArgInput(formDialog, 'Argument 2') + fireEvent.input(arg2Input, { + target: { value: '2' }, + }) + + await setCheckbox(formDialog, user, 'Set fee automatically', false) + const feeInput = await within(formDialog).findByLabelText(/Fee/) + fireEvent.input(feeInput, { + target: { value: '0' }, + }) + + await user.click(await component.findByRole('button', { name: 'Add' })) + + await waitFor(() => { + const requiredValidationMessages = within(formDialog).queryAllByText('Required') + expect(requiredValidationMessages.length).toBe(0) + }) + + // Send the transaction + await user.click(await component.findByRole('button', { name: sendButtonLabel })) + + const errorMessage = await component.findByText( + 'Network request error. Received status 400 (Bad Request): txgroup had 0 in fees, which is less than the minimum 1 * 1000' + ) + expect(errorMessage).toBeInTheDocument() + } + ) + }) + it('fee can be be shared between transactions', async () => { + const myStore = getTestStore() + vi.mocked(useParams).mockImplementation(() => ({ applicationId: appId.toString() })) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component, user) => { + const addMethodPanel = await expandMethodAccordion(component, user, 'add') + + // Open the build transaction dialog + const callButton = await waitFor(() => { + const addTransactionButton = within(addMethodPanel).getByRole('button', { name: 'Call' }) + expect(addTransactionButton).not.toBeDisabled() + return addTransactionButton! + }) + await user.click(callButton) + + // Fill the form + const addTxnFormDialog = component.getByRole('dialog') + + const arg1Input = await getArgInput(addTxnFormDialog, 'Argument 1') + fireEvent.input(arg1Input, { + target: { value: '1' }, + }) + + const arg2Input = await getArgInput(addTxnFormDialog, 'Argument 2') + fireEvent.input(arg2Input, { + target: { value: '2' }, + }) + + await setCheckbox(addTxnFormDialog, user, 'Set fee automatically', false) + const feeInput = await within(addTxnFormDialog).findByLabelText(/Fee/) + fireEvent.input(feeInput, { + target: { value: '0' }, + }) + + await user.click(await component.findByRole('button', { name: 'Add' })) + + await waitFor(() => { + const requiredValidationMessages = within(addTxnFormDialog).queryAllByText('Required') + expect(requiredValidationMessages.length).toBe(0) + }) + + // Add a payment transaction with 0.002 fee to cover the previous transaction + await user.click(await component.findByRole('button', { name: addTransactionLabel })) + + const paymentTxnFormDialog = component.getByRole('dialog') + + fireEvent.input(within(paymentTxnFormDialog).getByLabelText(/Receiver/), { + target: { value: localnet.context.testAccount.addr }, + }) + + fireEvent.input(within(paymentTxnFormDialog).getByLabelText(/Amount to pay/), { target: { value: '1' } }) + + await setCheckbox(paymentTxnFormDialog, user, 'Set fee automatically', false) + fireEvent.input(await within(paymentTxnFormDialog).findByLabelText(/Fee/), { + target: { value: '0.002' }, + }) + + await user.click(await component.findByRole('button', { name: 'Add' })) + + // Send the transaction + await user.click(await component.findByRole('button', { name: sendButtonLabel })) + + // Check the result + const resultsDiv = await waitFor( + () => { + return component.getByText(groupSendResultsLabel).parentElement! + }, + { timeout: 10_000 } + ) + + const transactionId = await waitFor( + () => { + const transactionLink = within(resultsDiv) + .getAllByRole('link') + .find((a) => a.getAttribute('href')?.startsWith('/localnet/transaction'))! + return transactionLink.getAttribute('href')!.split('/').pop()! + }, + { timeout: 10_000 } + ) + + const result = await localnet.context.waitForIndexerTransaction(transactionId) + expect(result.transaction.sender).toBe(localnet.context.testAccount.addr) + expect(result.transaction.fee).toBe(0) + expect(result.transaction['logs']!).toMatchInlineSnapshot(` + [ + "FR98dQAAAAAAAAAD", + ] + `) + } + ) + }) }) describe('when calling get_pay_txn_amount method', () => { @@ -1972,3 +2122,12 @@ const getStructArgInput = async (parentComponent: HTMLElement, argName: string, return within(fieldDiv).getByLabelText(/Value/) } + +const setCheckbox = async (parentComponent: HTMLElement, user: UserEvent, label: string, checked: boolean) => { + const wrapperDiv = within(parentComponent).getByLabelText(label).parentElement! + const btn = within(wrapperDiv).getByRole('checkbox') + const isChecked = btn.getAttribute('aria-checked') === 'true' + if (isChecked !== checked) { + await user.click(btn) + } +} diff --git a/src/features/forms/data/common.ts b/src/features/forms/data/common.ts index 2208a5e96..cfa9062d3 100644 --- a/src/features/forms/data/common.ts +++ b/src/features/forms/data/common.ts @@ -6,7 +6,7 @@ export const bigIntSchema = (schema: TSchema) => { z.coerce .string() .optional() - .transform((val) => (val ? BigInt(val) : undefined)) + .transform((val) => (val != null ? BigInt(val) : undefined)) .pipe(schema) ) } @@ -16,6 +16,6 @@ export const numberSchema = (schema: TSchema) => z.coerce .string() .optional() - .transform((val) => (val ? Number(val) : undefined)) + .transform((val) => (val != null ? Number(val) : undefined)) .pipe(schema) ) diff --git a/src/features/transaction-wizard/components/transaction-builder-fee-field.tsx b/src/features/transaction-wizard/components/transaction-builder-fee-field.tsx index cea6d1b55..019ff3c0c 100644 --- a/src/features/transaction-wizard/components/transaction-builder-fee-field.tsx +++ b/src/features/transaction-wizard/components/transaction-builder-fee-field.tsx @@ -38,7 +38,7 @@ export function TransactionBuilderFeeField() { ), field: feeValuePath, placeholder: '0.001', - helpText: 'Min 0.001 ALGO', + helpText: 'Min 0 ALGO', decimalScale: 6, thousandSeparator: true, required: true, diff --git a/src/features/transaction-wizard/data/common.ts b/src/features/transaction-wizard/data/common.ts index 609c269f1..90dba3b3a 100644 --- a/src/features/transaction-wizard/data/common.ts +++ b/src/features/transaction-wizard/data/common.ts @@ -56,10 +56,10 @@ export const feeFieldSchema = { fee: z .object({ setAutomatically: z.boolean(), - value: numberSchema(z.number().min(0.001).optional()), + value: numberSchema(z.number().min(0).optional()), }) .superRefine((fee, ctx) => { - if (!fee.setAutomatically && !fee.value) { + if (!fee.setAutomatically && fee.value == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: requiredMessage, diff --git a/src/features/transaction-wizard/mappers/as-algosdk-transactions.ts b/src/features/transaction-wizard/mappers/as-algosdk-transactions.ts index 2a496142e..cc60660d5 100644 --- a/src/features/transaction-wizard/mappers/as-algosdk-transactions.ts +++ b/src/features/transaction-wizard/mappers/as-algosdk-transactions.ts @@ -332,7 +332,7 @@ const asKeyRegistrationTransaction = async (transaction: BuildKeyRegistrationTra } const asFee = (fee: BuildAssetCreateTransactionResult['fee']) => - !fee.setAutomatically && fee.value ? { staticFee: algos(fee.value) } : undefined + !fee.setAutomatically && fee.value != null ? { staticFee: algos(fee.value) } : undefined const asValidRounds = (validRounds: BuildAssetCreateTransactionResult['validRounds']) => !validRounds.setAutomatically && validRounds.firstValid && validRounds.lastValid