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={
-
- {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);
+ });
+});