diff --git a/package.json b/package.json index dec15816501..75507674b5b 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "@leather.io/constants": "0.9.2", "@leather.io/crypto": "1.4.2", "@leather.io/models": "0.13.0", - "@leather.io/query": "2.8.0", + "@leather.io/query": "2.10.0", "@leather.io/stacks": "1.0.2", "@leather.io/tokens": "0.9.0", "@leather.io/ui": "1.17.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa8f392a127..0ad3e6ce0c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,8 +47,8 @@ importers: specifier: 0.13.0 version: 0.13.0 '@leather.io/query': - specifier: 2.8.0 - version: 2.8.0(@stacks/network@6.13.0(encoding@0.1.13))(encoding@0.1.13)(react@18.3.1) + specifier: 2.10.0 + version: 2.10.0(@stacks/network@6.13.0(encoding@0.1.13))(encoding@0.1.13)(react@18.3.1) '@leather.io/stacks': specifier: 1.0.2 version: 1.0.2(encoding@0.1.13) @@ -2775,6 +2775,9 @@ packages: '@leather.io/bitcoin@0.11.2': resolution: {integrity: sha512-w+wq1OXUbhydHLP0rDbDwoRk7YdjyIUPKx/roGfDDO56+BQqgQ4v6B0btyAv7VCbD5blw1FTGln3e0RNYOGWoA==} + '@leather.io/bitcoin@0.12.0': + resolution: {integrity: sha512-pTi6vQAJhKvnFUqVnI1sBClw3djGF8igEfBwRR6nJpGwzhg33kgyUW0iN08l1diFRRfvsOIv6tWsC3PNY6ulWQ==} + '@leather.io/constants@0.9.1': resolution: {integrity: sha512-q0chtXhgFIyNioNbxZIFBaDDxjcwBLFSph6wx+3Vl0uwdDUaDNyDdd+WsoXEFxESCesyCWiAr4aS12hmfGIwIg==} @@ -2784,6 +2787,9 @@ packages: '@leather.io/crypto@1.4.2': resolution: {integrity: sha512-sSBub/+feiHHXP+PY1UK+EvkROJTwo2rv+G9Hr5rAHrVZTg7c3SSUg+cdMOJANjJ2sw76U7XejWWuuTRYmabjg==} + '@leather.io/crypto@1.5.0': + resolution: {integrity: sha512-9kRexVALrkL6B6pqeulErBcY5YE3GJk3Ob4lhJuKXmNlRamfYkWD73cbiSerM6cmH0vyuxow+5M+LFZP/95mrQ==} + '@leather.io/eslint-config@0.7.0': resolution: {integrity: sha512-4K7olfSC+mJnG90TSaLIlytp14yDprGXwe1+oP9TLQbuPFpJai3/+g5Bp/FeUC4NZ23UVbAlGXFCav2amBb77w==} @@ -2796,8 +2802,8 @@ packages: '@leather.io/prettier-config@0.6.0': resolution: {integrity: sha512-QBKtLanfxFxXBlR58U/j8a6lBI0xzJzqqi36fXpGVp+9mJoEf6Ro6xrtFrixjW6seY6EOva4OApVnnPBsvOC/w==} - '@leather.io/query@2.8.0': - resolution: {integrity: sha512-uLDe0C14J3ajo8UoUrYfePeNd9aRo2C4Sq4as+hMKLMZrucZI6yWqcV8T8CZEQEf30I223WZMjth2RGGA37KgQ==} + '@leather.io/query@2.10.0': + resolution: {integrity: sha512-GvtTbyWa+5k8h5vJoJOClLl7/nYl60eaMZqzdMBYZizwbJaTL5L0A5ndu3yqruVITDZ4R/S+FY2ILOCr1grt/g==} peerDependencies: react: '*' @@ -17637,6 +17643,28 @@ snapshots: transitivePeerDependencies: - encoding + '@leather.io/bitcoin@0.12.0(encoding@0.1.13)': + dependencies: + '@bitcoinerlab/secp256k1': 1.0.2 + '@leather.io/constants': 0.9.2 + '@leather.io/crypto': 1.5.0 + '@leather.io/models': 0.13.0 + '@leather.io/utils': 0.13.3 + '@noble/hashes': 1.4.0 + '@noble/secp256k1': 2.1.0 + '@scure/base': 1.1.6 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + '@scure/btc-signer': 1.3.2 + '@stacks/common': 6.13.0 + '@stacks/transactions': 6.15.0(encoding@0.1.13) + bip32: 4.0.0 + bitcoinjs-lib: 6.1.5 + ecpair: 2.1.0 + varuint-bitcoin: 1.1.2 + transitivePeerDependencies: + - encoding + '@leather.io/constants@0.9.1': {} '@leather.io/constants@0.9.2': {} @@ -17647,6 +17675,12 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 + '@leather.io/crypto@1.5.0': + dependencies: + '@leather.io/utils': 0.13.3 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + '@leather.io/eslint-config@0.7.0(typescript@5.4.5)': dependencies: '@typescript-eslint/eslint-plugin': 6.9.0(@typescript-eslint/parser@6.9.0(eslint@8.56.0)(typescript@5.4.5))(eslint@8.56.0)(typescript@5.4.5) @@ -17678,15 +17712,15 @@ snapshots: - '@vue/compiler-sfc' - supports-color - '@leather.io/query@2.8.0(@stacks/network@6.13.0(encoding@0.1.13))(encoding@0.1.13)(react@18.3.1)': + '@leather.io/query@2.10.0(@stacks/network@6.13.0(encoding@0.1.13))(encoding@0.1.13)(react@18.3.1)': dependencies: '@fungible-systems/zone-file': 2.0.0 '@hirosystems/token-metadata-api-client': 1.2.0(encoding@0.1.13) - '@leather.io/bitcoin': 0.11.2(encoding@0.1.13) - '@leather.io/constants': 0.9.1 + '@leather.io/bitcoin': 0.12.0(encoding@0.1.13) + '@leather.io/constants': 0.9.2 '@leather.io/models': 0.13.0 '@leather.io/rpc': 2.1.6 - '@leather.io/utils': 0.13.2 + '@leather.io/utils': 0.13.3 '@noble/hashes': 1.4.0 '@scure/base': 1.1.6 '@scure/bip32': 1.4.0 diff --git a/src/app/features/collectibles/components/bitcoin/inscription-text.tsx b/src/app/features/collectibles/components/bitcoin/inscription-text.tsx index 8fda55b55fd..9a58cfe8488 100644 --- a/src/app/features/collectibles/components/bitcoin/inscription-text.tsx +++ b/src/app/features/collectibles/components/bitcoin/inscription-text.tsx @@ -1,3 +1,5 @@ +import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; + import { useGetInscriptionTextContentQuery } from '@leather.io/query'; import { OrdinalAvatarIcon } from '@leather.io/ui'; @@ -23,6 +25,7 @@ export function InscriptionText({ return ( } key={inscriptionNumber} onClickCallToAction={onClickCallToAction} diff --git a/src/app/features/collectibles/components/collectible-item.layout.tsx b/src/app/features/collectibles/components/collectible-item.layout.tsx index 41415a15c41..9cbe00a46d8 100644 --- a/src/app/features/collectibles/components/collectible-item.layout.tsx +++ b/src/app/features/collectibles/components/collectible-item.layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; +import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { Box, Stack, styled } from 'leather-styles/jsx'; import { token } from 'leather-styles/tokens'; import { useHover } from 'use-events'; @@ -26,6 +27,7 @@ export function CollectibleItemLayout({ showBorder, subtitle, title, + ...rest }: CollectibleItemLayoutProps) { const [isHovered, bind] = useHover(); @@ -42,6 +44,7 @@ export function CollectibleItemLayout({ p="space.01" textAlign="inherit" width="100%" + {...rest} {...bind} > @@ -88,6 +91,7 @@ export function CollectibleItemLayout({ {onClickSend ? ( 1) { - setShowError('Sending inscription from utxo with multiple inscriptions is unsupported'); + setShowError(FormErrorMessages.UtxoWithMultipleInscriptions); return; } + + // Check tx with lowest fee for errors before routing and + // generating the final transaction with the chosen fee to send + const resp = coverFeeFromAdditionalUtxos(values); + + if (!resp) { + setShowError(FormErrorMessages.InsufficientFundsToCoverFee); + return; + } + + navigate( + `/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionChooseFee}`, + { + state: { + inscription, + recipient: values.recipient, + utxo, + backgroundLocation: { pathname: RouteUrls.Home }, + }, + } + ); } catch (error) { void analytics.track('ordinals_dot_com_unavailable', { error }); if (error instanceof InsufficientFundsError) { - setShowError( - 'Insufficient funds to cover fee. Deposit some BTC to your Native Segwit address.' - ); + setShowError(FormErrorMessages.InsufficientFundsToCoverFee); return; } @@ -90,18 +98,6 @@ export function useSendInscriptionForm() { } finally { setIsCheckingFees(false); } - - navigate( - `/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionChooseFee}`, - { - state: { - inscription, - recipient: values.recipient, - utxo, - backgroundLocation: { pathname: RouteUrls.Home }, - }, - } - ); }, async reviewTransaction( diff --git a/src/app/pages/send/ordinal-inscription/send-inscription-form.tsx b/src/app/pages/send/ordinal-inscription/send-inscription-form.tsx index ae980dbfbaf..4ea39e52252 100644 --- a/src/app/pages/send/ordinal-inscription/send-inscription-form.tsx +++ b/src/app/pages/send/ordinal-inscription/send-inscription-form.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router-dom'; +import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { Form, Formik } from 'formik'; import { Box, Flex } from 'leather-styles/jsx'; @@ -8,6 +9,7 @@ import { Button, OrdinalAvatarIcon, Sheet, SheetHeader } from '@leather.io/ui'; import { RouteUrls } from '@shared/route-urls'; import { ErrorLabel } from '@app/components/error-label'; +import { TextInputFieldError } from '@app/components/field-error'; import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview'; import { InscriptionPreviewCard } from '@app/components/inscription-preview-card/inscription-preview-card'; @@ -44,7 +46,12 @@ export function SendInscriptionForm() { isShowing onClose={() => navigate(RouteUrls.Home)} footer={ - } @@ -64,9 +71,14 @@ export function SendInscriptionForm() { label="To" placeholder="Enter recipient address" /> + - {currentError && {currentError}} + {currentError && ( + + {currentError} + + )} diff --git a/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx b/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx index 858ea1c5f64..db8aa28b49f 100644 --- a/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx +++ b/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx @@ -1,6 +1,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { bytesToHex } from '@noble/hashes/utils'; +import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { Box, Flex, Stack } from 'leather-styles/jsx'; import get from 'lodash.get'; @@ -75,6 +76,7 @@ export function SendInscriptionReview() { onClose={() => navigate(RouteUrls.Home)} > - } /> + } + /> {arrivesIn && } diff --git a/src/shared/error-messages.ts b/src/shared/error-messages.ts index e5a25ce7348..38a74ba4801 100644 --- a/src/shared/error-messages.ts +++ b/src/shared/error-messages.ts @@ -17,4 +17,8 @@ export enum FormErrorMessages { MustSelectAsset = 'Select a valid token to transfer', SameAddress = 'Cannot send to yourself', TooMuchPrecision = 'Token can only have {decimals} decimals', + + NonZeroOffsetInscription = 'Sending inscriptions at non-zero offsets is unsupported', + UtxoWithMultipleInscriptions = 'Sending inscription from utxo with multiple inscriptions is unsupported', + InsufficientFundsToCoverFee = 'Insufficient funds to cover fee. Deposit some BTC to your Native Segwit address.', } diff --git a/tests/mocks/constants.ts b/tests/mocks/constants.ts index 936e7ef3461..63c1048fefd 100644 --- a/tests/mocks/constants.ts +++ b/tests/mocks/constants.ts @@ -4,8 +4,17 @@ export const STANDARD_BIP_FAKE_MNEMONIC = export const TEST_ACCOUNT_1_NATIVE_SEGWIT_ADDRESS = 'bc1q530dz4h80kwlzywlhx2qn0k6vdtftd93c499yq'; export const TEST_ACCOUNT_1_TAPROOT_ADDRESS = 'bc1putuzj9lyfcm8fef9jpy85nmh33cxuq9u6wyuk536t9kemdk37yjqmkc0pg'; +export const TEST_ACCOUNT_2_TAPROOT_ADDRESS = + 'bc1pmk2sacpfyy4v5phl8tq6eggu4e8laztep7fsgkkx0nc6m9vydjesaw0g2r'; + +export const TEST_TESNET_ACCOUNT_1_NATIVE_SEGWIT_ADDRESS = + 'tb1q4qgnjewwun2llgken94zqjrx5kpqqycaz5522d'; + export const TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS = 'tb1qr8me8t9gu9g6fu926ry5v44yp0wyljrespjtnz'; +export const TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS = + 'tb1pve00jmp43whpqj2wpcxtc7m8wqhz0azq689y4r7h8tmj8ltaj87qj2nj6w'; + // Stacks test addresses export const TEST_ACCOUNT_1_STX_ADDRESS = 'SPS8CKF63P16J28AYF7PXW9E5AACH0NZNTEFWSFE'; export const TEST_ACCOUNT_2_STX_ADDRESS = 'SPXH3HNBPM5YP15VH16ZXZ9AX6CK289K3MCXRKCB'; diff --git a/tests/mocks/mock-inscriptions-bis.ts b/tests/mocks/mock-inscriptions-bis.ts new file mode 100644 index 00000000000..ce4cd61c353 --- /dev/null +++ b/tests/mocks/mock-inscriptions-bis.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test'; + +import { BESTINSLOT_API_BASE_URL_TESTNET } from '@leather.io/models'; +import { type BestInSlotInscriptionResponse } from '@leather.io/query'; + +import { TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS } from './constants'; + +export async function mockTestnetTestAccountInscriptionsRequests( + page: Page, + inscriptions: BestInSlotInscriptionResponse[] +) { + await page.route(`${BESTINSLOT_API_BASE_URL_TESTNET}/wallet/inscriptions_batch`, async route => { + const request = route.request(); + const data = request.postData(); + const requestBody = data ? JSON.parse(data) : {}; + + if (requestBody.addresses?.includes(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS)) { + await route.fulfill({ + json: { block_height: 859832, data: inscriptions }, + }); + return; + } + await route.fulfill({ + json: { block_height: 859832, data: [] }, + }); + }); +} diff --git a/tests/mocks/mock-utxos.ts b/tests/mocks/mock-utxos.ts index afbe5bafac0..1a85499fc62 100644 --- a/tests/mocks/mock-utxos.ts +++ b/tests/mocks/mock-utxos.ts @@ -1,5 +1,7 @@ import type { Page } from '@playwright/test'; +import { BITCOIN_API_BASE_URL_TESTNET } from '@leather.io/models'; + import { TEST_ACCOUNT_1_NATIVE_SEGWIT_ADDRESS } from './constants'; export const mockUtxos = [ @@ -129,3 +131,11 @@ export async function mockMainnetTestAccountBitcoinRequests(page: Page) { ), ]); } + +export async function mockTestnetTestAccountEmptyUtxosRequests(page: Page) { + await page.route(`${BITCOIN_API_BASE_URL_TESTNET}/address/**/utxo`, route => + route.fulfill({ + json: [], + }) + ); +} diff --git a/tests/page-object-models/send.page.ts b/tests/page-object-models/send.page.ts index 70f05055e91..e9476876a78 100644 --- a/tests/page-object-models/send.page.ts +++ b/tests/page-object-models/send.page.ts @@ -109,4 +109,19 @@ export class SendPage { async waitForFeeRow() { await this.page.getByTestId(SharedComponentsSelectors.FeeRow).waitFor({ state: 'attached' }); } + + async selectInscription() { + const inscriptions = this.page.getByTestId(SendCryptoAssetSelectors.Inscription); + const sendButton = this.page.getByTestId(SendCryptoAssetSelectors.InscriptionSendButton); + const count = await inscriptions.count(); + if (count === 1) { + await inscriptions.hover(); + await this.page + .getByTestId(SendCryptoAssetSelectors.InscriptionSendButton) + .click({ force: true }); + } else { + await inscriptions.nth(0).hover(); + await sendButton.nth(0).click({ force: true }); + } + } } diff --git a/tests/selectors/send.selectors.ts b/tests/selectors/send.selectors.ts index ef88ac8265e..2b34040ae16 100644 --- a/tests/selectors/send.selectors.ts +++ b/tests/selectors/send.selectors.ts @@ -27,4 +27,8 @@ export enum SendCryptoAssetSelectors { // stx high fee warning dialog HighFeeWarningSheet = 'high-fee-warning-sheet', HighFeeWarningSheetSubmit = 'high-fee-warning-sheet-submit', + + // inscription + Inscription = 'inscription', + InscriptionSendButton = 'inscription-send-button', } diff --git a/tests/specs/send/send-inscription.spec.ts b/tests/specs/send/send-inscription.spec.ts new file mode 100644 index 00000000000..f6ec8b90372 --- /dev/null +++ b/tests/specs/send/send-inscription.spec.ts @@ -0,0 +1,113 @@ +import { TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS } from '@tests/mocks/constants'; +import { mockTestnetTestAccountInscriptionsRequests } from '@tests/mocks/mock-inscriptions-bis'; +import { mockTestnetTestAccountEmptyUtxosRequests } from '@tests/mocks/mock-utxos'; +import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; +import { getDisplayerAddress } from '@tests/utils'; + +import { BtcFeeType } from '@leather.io/models'; +import { mockInscriptionResponse3, mockInscriptionResponseNonZeroOffset } from '@leather.io/query'; + +import { FormErrorMessages } from '@shared/error-messages'; + +import { test } from '../../fixtures/fixtures'; + +test.describe('send inscription', () => { + test.beforeEach(async ({ extensionId, globalPage, onboardingPage }) => { + await globalPage.setupAndUseApiCalls(extensionId); + await onboardingPage.signInWithTestAccount(extensionId); + await mockTestnetTestAccountInscriptionsRequests(globalPage.page, [mockInscriptionResponse3]); + }); + + test.describe('valid send inscription data', () => { + test('should show the inscription review step', async ({ sendPage, homePage }) => { + await homePage.selectTestnet(); + await sendPage.selectInscription(); + await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS); + const inscriptionSendButton = sendPage.page.getByTestId( + SendCryptoAssetSelectors.PreviewSendTxBtn + ); + await inscriptionSendButton.click(); + await sendPage.feesListItem.filter({ hasText: BtcFeeType.Low }).click(); + const displayerAddress = await getDisplayerAddress(sendPage.confirmationDetailsRecipient); + test.expect(displayerAddress).toEqual(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS); + }); + }); + + test.describe('validation errors', () => { + test('should show the insufficient balance error', async ({ + globalPage, + homePage, + sendPage, + }) => { + await mockTestnetTestAccountEmptyUtxosRequests(globalPage.page); + await homePage.selectTestnet(); + await sendPage.selectInscription(); + + await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS); + const inscriptionSendButton = sendPage.page.getByTestId( + SendCryptoAssetSelectors.PreviewSendTxBtn + ); + await inscriptionSendButton.click(); + + const errorLabel = await sendPage.formInputErrorLabel.textContent(); + test.expect(errorLabel).toContain(FormErrorMessages.InsufficientFunds); + }); + + test('should show invalid address error', async ({ homePage, sendPage }) => { + await homePage.selectTestnet(); + await sendPage.selectInscription(); + + await sendPage.recipientInput.fill('123'); + const inscriptionSendButton = sendPage.page.getByTestId( + SendCryptoAssetSelectors.PreviewSendTxBtn + ); + await inscriptionSendButton.click(); + + const errorMsg = await sendPage.formInputErrorLabel.textContent(); + test.expect(errorMsg).toContain(FormErrorMessages.InvalidAddress); + }); + + test('should show non-zero offset inscription error', async ({ + globalPage, + homePage, + sendPage, + }) => { + await mockTestnetTestAccountInscriptionsRequests(globalPage.page, [ + mockInscriptionResponseNonZeroOffset, + ]); + await homePage.selectTestnet(); + await sendPage.selectInscription(); + + await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS); + const inscriptionSendButton = sendPage.page.getByTestId( + SendCryptoAssetSelectors.PreviewSendTxBtn + ); + await inscriptionSendButton.click(); + + const errorLabel = await sendPage.formInputErrorLabel.textContent(); + test.expect(errorLabel).toContain(FormErrorMessages.NonZeroOffsetInscription); + }); + }); + + test('should show multiple inscription on utxo error', async ({ + globalPage, + homePage, + sendPage, + }) => { + await mockTestnetTestAccountInscriptionsRequests(globalPage.page, [ + mockInscriptionResponse3, + mockInscriptionResponse3, + ]); + await homePage.selectTestnet(); + await sendPage.selectInscription(); + + await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_TAPROOT_ADDRESS); + const inscriptionSendButton = sendPage.page.getByTestId( + SendCryptoAssetSelectors.PreviewSendTxBtn + ); + await inscriptionSendButton.click(); + + const errorLabel = await sendPage.formInputErrorLabel.textContent(); + test.expect(errorLabel).toContain(FormErrorMessages.UtxoWithMultipleInscriptions); + }); +});