diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 2fedd1bee..15e59c093 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -68,6 +68,7 @@
"@segment/sovran-react-native": "1.1.2",
"@shopify/restyle": "2.4.2",
"@stacks/common": "6.13.0",
+ "@stacks/network": "6.13.0",
"@stacks/stacks-blockchain-api-types": "7.8.2",
"@stacks/transactions": "6.17.0",
"@stacks/wallet-sdk": "6.15.0",
@@ -106,6 +107,7 @@
"metro-resolver": "0.80.5",
"prism-react-renderer": "2.4.0",
"react": "18.2.0",
+ "react-async-hook": "4.0.0",
"react-dom": "18.2.0",
"react-hook-form": "7.53.2",
"react-native": "0.74.1",
diff --git a/apps/mobile/src/common/transactions/stacks-transactions.hooks.ts b/apps/mobile/src/common/transactions/stacks-transactions.hooks.ts
new file mode 100644
index 000000000..8806a2b02
--- /dev/null
+++ b/apps/mobile/src/common/transactions/stacks-transactions.hooks.ts
@@ -0,0 +1,51 @@
+import { useCallback } from 'react';
+import { FieldValues } from 'react-hook-form';
+
+import { AccountId } from '@/models/domain.model';
+import { useAccountByIndex } from '@/store/accounts/accounts.read';
+import { useStacksSigners } from '@/store/keychains/stacks/stacks-keychains.read';
+import { useNetworkPreferenceStacksNetwork } from '@/store/settings/settings.read';
+import { bytesToHex } from '@noble/hashes/utils';
+
+import { useNextNonce } from '@leather.io/query';
+import {
+ GenerateUnsignedTransactionOptions,
+ TransactionTypes,
+ generateUnsignedTransaction,
+} from '@leather.io/stacks';
+import { stxToMicroStx } from '@leather.io/utils';
+
+export function useGenerateStxTokenTransferUnsignedTx({ fingerprint, accountIndex }: AccountId) {
+ const network = useNetworkPreferenceStacksNetwork();
+ const account = useAccountByIndex(fingerprint, accountIndex);
+ const stxSigner = useStacksSigners().fromAccountIndex(fingerprint, accountIndex)[0];
+ const stxAddress = stxSigner?.address ?? '';
+ const { data: nextNonce } = useNextNonce(stxAddress);
+
+ return useCallback(
+ async (values?: FieldValues) => {
+ if (!account || !stxSigner) return;
+
+ const options: GenerateUnsignedTransactionOptions = {
+ publicKey: bytesToHex(stxSigner.publicKey),
+ nonce: Number(values?.nonce) ?? nextNonce?.nonce,
+ fee: stxToMicroStx(values?.fee || 0).toNumber(),
+ txData: {
+ txType: TransactionTypes.STXTransfer,
+ // Using account address here as a fallback for a fee estimation
+ recipient: values?.recipient ?? stxAddress,
+ amount: values?.amount ? stxToMicroStx(values?.amount).toString(10) : '0',
+ memo: values?.memo || undefined,
+ network,
+ // Coercing type here as we don't have the public key
+ // as expected by STXTransferPayload type.
+ // This code will likely need to change soon with Ledger
+ // work, and coercion allows us to remove lots of type mangling
+ // and types are out of sync with @stacks/connect
+ } as any,
+ };
+ return generateUnsignedTransaction(options);
+ },
+ [account, stxSigner, nextNonce?.nonce, stxAddress, network]
+ );
+}
diff --git a/apps/mobile/src/features/account-list/account-list.tsx b/apps/mobile/src/features/account-list/account-list.tsx
index c19fa6235..ebfa41b38 100644
--- a/apps/mobile/src/features/account-list/account-list.tsx
+++ b/apps/mobile/src/features/account-list/account-list.tsx
@@ -34,7 +34,7 @@ export function AccountList({ accounts, onPress, showWalletInfo }: AccountListPr
iconTestID={defaultIconTestId(account.icon)}
onPress={() => onPress(account)}
testID={TestId.walletListAccountCard}
- walletName={showWalletInfo ? wallet.name : ' '}
+ walletName={showWalletInfo ? wallet.name : undefined}
/>
)}
diff --git a/apps/mobile/src/features/balances/bitcoin/bitcoin-balance.tsx b/apps/mobile/src/features/balances/bitcoin/bitcoin-balance.tsx
index ffeb598d0..2dd33de19 100644
--- a/apps/mobile/src/features/balances/bitcoin/bitcoin-balance.tsx
+++ b/apps/mobile/src/features/balances/bitcoin/bitcoin-balance.tsx
@@ -27,10 +27,7 @@ export function BitcoinTokenBalance({
id: 'asset_name.bitcoin',
message: 'Bitcoin',
})}
- chain={t({
- id: 'asset_name.layer_1',
- message: 'Layer 1',
- })}
+ protocol="nativeBtc"
fiatBalance={fiatBalance}
availableBalance={availableBalance}
onPress={onPress}
diff --git a/apps/mobile/src/features/balances/stacks/stacks-balance.tsx b/apps/mobile/src/features/balances/stacks/stacks-balance.tsx
index 4ca0b410a..cbf0dffe6 100644
--- a/apps/mobile/src/features/balances/stacks/stacks-balance.tsx
+++ b/apps/mobile/src/features/balances/stacks/stacks-balance.tsx
@@ -25,10 +25,7 @@ export function StacksTokenBalance({
id: 'asset_name.stacks',
message: 'Stacks',
})}
- chain={t({
- id: 'asset_name.layer_1',
- message: 'Layer 1',
- })}
+ protocol="nativeStx"
fiatBalance={fiatBalance}
availableBalance={availableBalance}
onPress={onPress}
diff --git a/apps/mobile/src/features/balances/token-balance.tsx b/apps/mobile/src/features/balances/token-balance.tsx
index f51d8f198..c040d0629 100644
--- a/apps/mobile/src/features/balances/token-balance.tsx
+++ b/apps/mobile/src/features/balances/token-balance.tsx
@@ -1,16 +1,29 @@
import { ReactNode } from 'react';
import { Balance } from '@/components/balance/balance';
+import { t } from '@lingui/macro';
-import { Money } from '@leather.io/models';
+import { CryptoAssetProtocol, Money } from '@leather.io/models';
import { Flag, ItemLayout, Pressable } from '@leather.io/ui/native';
+export function getChainLayerFromAssetProtocol(protocol: CryptoAssetProtocol) {
+ switch (protocol) {
+ case 'nativeBtc':
+ case 'nativeStx':
+ return t({ id: 'account_balance.caption_left.native', message: 'Layer 1' });
+ case 'sip10':
+ return t({ id: 'account_balance.caption_left.sip10', message: 'Layer 2 ยท Stacks' });
+ default:
+ return '';
+ }
+}
+
interface TokenBalanceProps {
ticker: string;
icon: ReactNode;
tokenName: string;
availableBalance?: Money;
- chain: string;
+ protocol: CryptoAssetProtocol;
fiatBalance: Money;
onPress?(): void;
}
@@ -19,7 +32,7 @@ export function TokenBalance({
icon,
tokenName,
availableBalance,
- chain,
+ protocol,
fiatBalance,
onPress,
}: TokenBalanceProps) {
@@ -29,7 +42,7 @@ export function TokenBalance({
}
- captionLeft={chain}
+ captionLeft={getChainLayerFromAssetProtocol(protocol)}
captionRight={}
/>
diff --git a/apps/mobile/src/features/send/utils.ts b/apps/mobile/src/features/send/send-form.utils.ts
similarity index 100%
rename from apps/mobile/src/features/send/utils.ts
rename to apps/mobile/src/features/send/send-form.utils.ts
diff --git a/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx b/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx
index f6b9581af..f6dff7194 100644
--- a/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx
+++ b/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx
@@ -1,7 +1,6 @@
import { Controller, useFormContext } from 'react-hook-form';
import { TextInput } from '@/components/text-input';
-import { t } from '@lingui/macro';
import { z } from 'zod';
import { useSendFormContext } from '../send-form-context';
@@ -29,12 +28,6 @@ export function SendFormAmountField() {
value={value}
/>
)}
- rules={{
- required: t({
- id: 'send-form.amount-field.error.amount-required',
- message: 'Amount is required',
- }),
- }}
/>
);
}
diff --git a/apps/mobile/src/features/send/send-form/components/send-form-asset.tsx b/apps/mobile/src/features/send/send-form/components/send-form-asset.tsx
index 37d3ddd6d..2ff0156c3 100644
--- a/apps/mobile/src/features/send/send-form/components/send-form-asset.tsx
+++ b/apps/mobile/src/features/send/send-form/components/send-form-asset.tsx
@@ -5,24 +5,22 @@ import { Box, Pressable } from '@leather.io/ui/native';
import { useSendFormContext } from '../send-form-context';
interface SendFormAssetProps {
- assetName: string;
- chain: string;
icon: React.ReactNode;
onPress(): void;
}
-export function SendFormAsset({ assetName, chain, icon, onPress }: SendFormAssetProps) {
- const { availableBalance, fiatBalance, symbol } = useSendFormContext();
+export function SendFormAsset({ icon, onPress }: SendFormAssetProps) {
+ const { name, protocol, availableBalance, fiatBalance, symbol } = useSendFormContext();
return (
diff --git a/apps/mobile/src/features/send/send-form/components/send-form-button.tsx b/apps/mobile/src/features/send/send-form/components/send-form-button.tsx
index 0c2652817..8e0b278c6 100644
--- a/apps/mobile/src/features/send/send-form/components/send-form-button.tsx
+++ b/apps/mobile/src/features/send/send-form/components/send-form-button.tsx
@@ -10,27 +10,26 @@ import { useSendFormContext } from '../send-form-context';
export function SendFormButton() {
const { displayToast } = useToastContext();
- const { schema } = useSendFormContext();
+ const { schema, onSubmit } = useSendFormContext();
const {
formState: { isDirty, isValid },
handleSubmit,
} = useFormContext>();
- function onSubmit(data: z.infer) {
+ function onPress(data: z.infer) {
+ onSubmit(data);
// Temporary toast for testing
displayToast({
title: t`Form submitted`,
type: 'success',
});
- // eslint-disable-next-line no-console
- console.log(t`submit data:`, data);
}
return (
{t({
- id: 'send-form.memo.input.label',
+ id: 'send_form.memo.input.label',
message: 'Add memo',
})}
diff --git a/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx b/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx
index 93bd972ac..cf1ddd056 100644
--- a/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx
+++ b/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx
@@ -49,7 +49,7 @@ export function SendFormRecipient() {
) : (
{t({
- id: 'send-form.recipient.input.label',
+ id: 'send_form.recipient.input.label',
message: 'Enter recipient',
})}
diff --git a/apps/mobile/src/features/send/send-form/components/send-form-stacks-setter.tsx b/apps/mobile/src/features/send/send-form/components/send-form-stacks-setter.tsx
new file mode 100644
index 000000000..acdf7da65
--- /dev/null
+++ b/apps/mobile/src/features/send/send-form/components/send-form-stacks-setter.tsx
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import { useFormContext } from 'react-hook-form';
+
+import { useSendFormContext } from '@/features/send/send-form/send-form-context';
+import { HasChildren } from '@/utils/types';
+import BigNumber from 'bignumber.js';
+import { z } from 'zod';
+
+import { defaultStacksFees, useNextNonce } from '@leather.io/query';
+import { useOnMount } from '@leather.io/ui/native';
+import { microStxToStx } from '@leather.io/utils';
+
+const defaultFeeFallback = 2500;
+
+interface SendFormStacksSetterProps extends HasChildren {
+ address: string;
+}
+export function SendFormStacksSetter({ address, children }: SendFormStacksSetterProps) {
+ const { schema } = useSendFormContext();
+ const { setValue, formState, getValues } = useFormContext>();
+ const { data: nextNonce } = useNextNonce(address);
+
+ // Temporary default fee
+ const defaultFee =
+ defaultStacksFees.estimates[0]?.fee.amount ?? new BigNumber(defaultFeeFallback);
+
+ useOnMount(() => setValue('fee', microStxToStx(defaultFee).toString()));
+
+ useEffect(() => {
+ if (
+ nextNonce?.nonce &&
+ !formState.dirtyFields.nonce &&
+ getValues('nonce') !== nextNonce.nonce
+ ) {
+ return setValue('nonce', nextNonce?.nonce);
+ }
+ }, [formState.dirtyFields.nonce, getValues, nextNonce?.nonce, setValue]);
+
+ return children;
+}
diff --git a/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx b/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx
new file mode 100644
index 000000000..bb54f97dc
--- /dev/null
+++ b/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx
@@ -0,0 +1,64 @@
+import { useForm } from 'react-hook-form';
+
+import { useGenerateStxTokenTransferUnsignedTx } from '@/common/transactions/stacks-transactions.hooks';
+import { useGetStacksAddresses } from '@/features/balances/stacks/use-get-stacks-addresses';
+import { useStxBalance } from '@/queries/balance/stacks-balance.query';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { bytesToHex } from '@noble/hashes/utils';
+
+import {
+ CreateCurrentSendRoute,
+ useSendSheetNavigation,
+ useSendSheetRoute,
+} from '../../send-form.utils';
+import {
+ SendFormStxSchema,
+ defaultSendFormStxValues,
+ sendFormStxSchema,
+} from '../schemas/send-form-stx.schema';
+
+export type CurrentRoute = CreateCurrentSendRoute<'send-form-stx'>;
+
+export function useSendFormStx() {
+ const route = useSendSheetRoute();
+ const navigation = useSendSheetNavigation();
+
+ const addresses = useGetStacksAddresses({
+ accountIndex: route.params.account.accountIndex,
+ fingerprint: route.params.account.fingerprint,
+ });
+ const { availableBalance, fiatBalance } = useStxBalance(addresses);
+
+ const formMethods = useForm({
+ mode: 'onChange',
+ defaultValues: defaultSendFormStxValues,
+ resolver: zodResolver(sendFormStxSchema),
+ });
+
+ const generateTx = useGenerateStxTokenTransferUnsignedTx({
+ accountIndex: route.params.account.accountIndex,
+ fingerprint: route.params.account.fingerprint,
+ });
+
+ return {
+ address: addresses[0] ?? '',
+ availableBalance,
+ fiatBalance,
+ formMethods,
+ onGoBack() {
+ navigation.navigate('send-select-asset', { account: route.params.account });
+ },
+ async onSubmit(data: SendFormStxSchema) {
+ // eslint-disable-next-line no-console, lingui/no-unlocalized-strings
+ console.log('Send form data:', data);
+ const tx = await generateTx(data);
+ // eslint-disable-next-line no-console, lingui/no-unlocalized-strings
+ console.log('Unsigned tx:', tx);
+ // Show an error toast here?
+ if (!tx) throw new Error('Attempted to generate unsigned tx, but tx is undefined');
+ const txHex = bytesToHex(tx.serialize());
+ // eslint-disable-next-line no-console, lingui/no-unlocalized-strings
+ console.log('tx hex:', txHex);
+ },
+ };
+}
diff --git a/apps/mobile/src/features/send/send-form/schemas/send-form-stx.schema.ts b/apps/mobile/src/features/send/send-form/schemas/send-form-stx.schema.ts
index b9444dfcb..5e8ad17eb 100644
--- a/apps/mobile/src/features/send/send-form/schemas/send-form-stx.schema.ts
+++ b/apps/mobile/src/features/send/send-form/schemas/send-form-stx.schema.ts
@@ -6,6 +6,7 @@ export const sendFormStxSchema = z.object({
}),
recipient: z.string(),
memo: z.string().optional(),
+ nonce: z.number(),
fee: z.string(),
});
@@ -15,5 +16,6 @@ export const defaultSendFormStxValues: SendFormStxSchema = {
amount: '',
recipient: '',
memo: '',
+ nonce: 0,
fee: '',
};
diff --git a/apps/mobile/src/features/send/send-form/send-form-context.tsx b/apps/mobile/src/features/send/send-form/send-form-context.tsx
index 233ce979f..6efad240c 100644
--- a/apps/mobile/src/features/send/send-form/send-form-context.tsx
+++ b/apps/mobile/src/features/send/send-form/send-form-context.tsx
@@ -6,12 +6,14 @@ import { ZodTypeAny } from 'zod';
import { CryptoAssetProtocol, CryptoCurrency, Money } from '@leather.io/models';
export interface SendFormContext {
+ name: string;
protocol: CryptoAssetProtocol;
symbol: CryptoCurrency;
availableBalance: Money;
fiatBalance: Money;
defaultValues: FieldValues;
schema: ZodTypeAny;
+ onSubmit(data: any): void;
}
const sendFormContext = createContext(null);
diff --git a/apps/mobile/src/features/send/send-form/send-form.tsx b/apps/mobile/src/features/send/send-form/send-form.tsx
index 0e0db7829..eb0075781 100644
--- a/apps/mobile/src/features/send/send-form/send-form.tsx
+++ b/apps/mobile/src/features/send/send-form/send-form.tsx
@@ -8,28 +8,41 @@ import { SendFormFooterLayout } from './components/send-form-footer.layout';
import { SendFormMemo } from './components/send-form-memo';
import { SendFormNumpad } from './components/send-form-numpad';
import { SendFormRecipient } from './components/send-form-recipient';
+import { SendFormStacksSetter } from './components/send-form-stacks-setter';
import { SendFormContext, SendFormProvider } from './send-form-context';
type SendFormProps = SendFormContext;
function SendForm({
+ name,
protocol,
symbol,
availableBalance,
fiatBalance,
defaultValues,
schema,
+ onSubmit,
...props
}: SendFormProps & HasChildren) {
return (
);
}
+SendForm.StacksSetter = SendFormStacksSetter;
SendForm.Asset = SendFormAsset;
SendForm.AmountField = SendFormAmountField;
SendForm.RecipientField = SendFormRecipient;
diff --git a/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx b/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx
index 9b22e408e..a7c284e3a 100644
--- a/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx
+++ b/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx
@@ -41,12 +41,6 @@ export function RecipientSheet({ sheetRef }: RecipientSheetProps) {
value={value}
/>
)}
- rules={{
- required: t({
- id: 'recipient-sheet.recipient.error.recipient_required',
- message: 'Recipient is required',
- }),
- }}
/>
diff --git a/apps/mobile/src/features/send/send-sheet-navigator.tsx b/apps/mobile/src/features/send/send-sheet-navigator.tsx
index 3c43b38bd..0231024b0 100644
--- a/apps/mobile/src/features/send/send-sheet-navigator.tsx
+++ b/apps/mobile/src/features/send/send-sheet-navigator.tsx
@@ -4,12 +4,12 @@ import { useTheme } from '@shopify/restyle';
import { Theme } from '@leather.io/ui/native';
+import { SendSheetNavigatorParamList } from './send-form.utils';
import { SelectAccountSheet } from './send-sheets/select-account-sheet';
import { SelectAssetSheet } from './send-sheets/select-asset-sheet';
import { SendFormBtcSheet } from './send-sheets/send-form-btc-sheet';
import { SendFormStxSheet } from './send-sheets/send-form-stx-sheet';
import { SignPsbt } from './send-sheets/sign-psbt';
-import { SendSheetNavigatorParamList } from './utils';
const Stack = createStackNavigator();
diff --git a/apps/mobile/src/features/send/send-sheets/select-account-sheet.tsx b/apps/mobile/src/features/send/send-sheets/select-account-sheet.tsx
index bd0f17307..df2a8cd14 100644
--- a/apps/mobile/src/features/send/send-sheets/select-account-sheet.tsx
+++ b/apps/mobile/src/features/send/send-sheets/select-account-sheet.tsx
@@ -6,7 +6,7 @@ import { Account } from '@/store/accounts/accounts';
import { useAccounts } from '@/store/accounts/accounts.read';
import { t } from '@lingui/macro';
-import { CreateCurrentSendRoute, useSendSheetNavigation } from '../utils';
+import { CreateCurrentSendRoute, useSendSheetNavigation } from '../send-form.utils';
type CurrentRoute = CreateCurrentSendRoute<'send-select-account'>;
diff --git a/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx b/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx
index e76bb7e17..1d9efe0eb 100644
--- a/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx
+++ b/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx
@@ -11,7 +11,11 @@ import { useTheme } from '@shopify/restyle';
import { Theme } from '@leather.io/ui/native';
-import { CreateCurrentSendRoute, useSendSheetNavigation, useSendSheetRoute } from '../utils';
+import {
+ CreateCurrentSendRoute,
+ useSendSheetNavigation,
+ useSendSheetRoute,
+} from '../send-form.utils';
type CurrentRoute = CreateCurrentSendRoute<'send-select-asset'>;
diff --git a/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx b/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx
index ab93fbcd7..189ae78db 100644
--- a/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx
+++ b/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx
@@ -10,13 +10,17 @@ import { useLingui } from '@lingui/react';
import { BtcAvatarIcon } from '@leather.io/ui/native';
+import {
+ CreateCurrentSendRoute,
+ useSendSheetNavigation,
+ useSendSheetRoute,
+} from '../send-form.utils';
import {
SendFormBtcSchema,
defaultSendFormBtcValues,
sendFormBtcSchema,
} from '../send-form/schemas/send-form-btc.schema';
import { SendForm } from '../send-form/send-form';
-import { CreateCurrentSendRoute, useSendSheetNavigation, useSendSheetRoute } from '../utils';
type CurrentRoute = CreateCurrentSendRoute<'send-form-btc'>;
export function SendFormBtcSheet() {
@@ -34,6 +38,10 @@ export function SendFormBtcSheet() {
fingerprint: route.params.account.fingerprint,
});
+ function onSubmit(data: SendFormBtcSchema) {
+ return data;
+ }
+
return (
}
onPress={() =>
navigation.navigate('send-select-asset', { account: route.params.account })
}
- icon={}
- assetName={t({
- id: 'asset_name.bitcoin',
- message: 'Bitcoin',
- })}
- chain={t({
- id: 'asset_name.layer_1',
- message: 'Layer 1',
- })}
/>
diff --git a/apps/mobile/src/features/send/send-sheets/send-form-stx-sheet.tsx b/apps/mobile/src/features/send/send-sheets/send-form-stx-sheet.tsx
index c222160e1..d40abd319 100644
--- a/apps/mobile/src/features/send/send-sheets/send-form-stx-sheet.tsx
+++ b/apps/mobile/src/features/send/send-sheets/send-form-stx-sheet.tsx
@@ -1,40 +1,29 @@
-import { FormProvider, useForm } from 'react-hook-form';
+import { FormProvider } from 'react-hook-form';
import { FullHeightSheetHeader } from '@/components/full-height-sheet/full-height-sheet-header';
import { FullHeightSheetLayout } from '@/components/full-height-sheet/full-height-sheet.layout';
-import { useGetStacksAddresses } from '@/features/balances/stacks/use-get-stacks-addresses';
import { NetworkBadge } from '@/features/settings/network-badge';
-import { useStxBalance } from '@/queries/balance/stacks-balance.query';
-import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { StxAvatarIcon } from '@leather.io/ui/native';
+import { CreateCurrentSendRoute, useSendSheetRoute } from '../send-form.utils';
+import { useSendFormStx } from '../send-form/hooks/use-send-form-stx';
import {
- SendFormStxSchema,
defaultSendFormStxValues,
sendFormStxSchema,
} from '../send-form/schemas/send-form-stx.schema';
import { SendForm } from '../send-form/send-form';
-import { CreateCurrentSendRoute, useSendSheetNavigation, useSendSheetRoute } from '../utils';
type CurrentRoute = CreateCurrentSendRoute<'send-form-stx'>;
export function SendFormStxSheet() {
const { i18n } = useLingui();
const route = useSendSheetRoute();
- const navigation = useSendSheetNavigation();
- const formMethods = useForm({
- defaultValues: defaultSendFormStxValues,
- resolver: zodResolver(sendFormStxSchema),
- });
- const addresses = useGetStacksAddresses({
- accountIndex: route.params.account.accountIndex,
- fingerprint: route.params.account.fingerprint,
- });
- const { availableBalance, fiatBalance } = useStxBalance(addresses);
+ const { address, availableBalance, fiatBalance, formMethods, onGoBack, onSubmit } =
+ useSendFormStx();
return (
-
- navigation.navigate('send-select-asset', { account: route.params.account })
- }
- icon={}
- assetName={t({
- id: 'asset_name.stacks',
- message: 'Stacks',
- })}
- chain={t({
- id: 'asset_name.layer_1',
- message: 'Layer 1',
- })}
- />
-
-
-
-
-
-
-
+
+ } onPress={onGoBack} />
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/src/store/settings/settings.read.ts b/apps/mobile/src/store/settings/settings.read.ts
index 41787e769..edd668510 100644
--- a/apps/mobile/src/store/settings/settings.read.ts
+++ b/apps/mobile/src/store/settings/settings.read.ts
@@ -1,15 +1,19 @@
+import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useBitcoinAccounts } from '@/store/keychains/bitcoin/bitcoin-keychains.read';
import { useStacksSignerAddressFromAccountIndex } from '@/store/keychains/stacks/stacks-keychains.read';
import { useSettings } from '@/store/settings/settings';
import { createSelector } from '@reduxjs/toolkit';
+import { StacksNetwork } from '@stacks/network';
+import { ChainID, TransactionVersion } from '@stacks/transactions';
import {
accountDisplayPreferencesKeyedByType,
bitcoinUnitsKeyedByName,
} from '@leather.io/constants';
import { defaultNetworksKeyedById } from '@leather.io/models';
+import { whenStacksChainId } from '@leather.io/stacks';
import type { RootState } from '..';
@@ -90,3 +94,21 @@ export function useAccountDisplayAddress(fingerprint: string, accountIndex: numb
return stxAddress;
}
}
+
+export function useNetworkPreferenceStacksNetwork(): StacksNetwork {
+ const { networkPreference } = useSettings();
+
+ return useMemo(() => {
+ const stacksNetwork = new StacksNetwork({ url: networkPreference.chain.stacks.url });
+
+ stacksNetwork.version = whenStacksChainId(networkPreference.chain.stacks.chainId)({
+ [ChainID.Mainnet]: TransactionVersion.Mainnet,
+ [ChainID.Testnet]: TransactionVersion.Testnet,
+ });
+ stacksNetwork.chainId =
+ networkPreference.chain.stacks.subnetChainId ?? networkPreference.chain.stacks.chainId;
+ stacksNetwork.bnsLookupUrl = networkPreference.chain.stacks.url || '';
+
+ return stacksNetwork;
+ }, [networkPreference]);
+}
diff --git a/packages/stacks/package.json b/packages/stacks/package.json
index db49c2bcd..0a411b669 100644
--- a/packages/stacks/package.json
+++ b/packages/stacks/package.json
@@ -19,15 +19,19 @@
".": "./dist/index.js"
},
"dependencies": {
+ "@leather.io/constants": "workspace:*",
"@leather.io/crypto": "workspace:*",
"@leather.io/models": "workspace:*",
"@leather.io/utils": "workspace:*",
"@noble/hashes": "1.5.0",
"@scure/bip32": "1.5.0",
"@stacks/encryption": "6.16.1",
- "@stacks/transactions": "6.17.0"
+ "@stacks/network": "6.13.0",
+ "@stacks/transactions": "6.17.0",
+ "bignumber.js": "9.1.2"
},
"devDependencies": {
+ "@stacks/stacks-blockchain-api-types": "7.8.2",
"tsup": "8.1.0"
},
"files": [
diff --git a/packages/stacks/src/index.ts b/packages/stacks/src/index.ts
index cd3075649..295931e42 100644
--- a/packages/stacks/src/index.ts
+++ b/packages/stacks/src/index.ts
@@ -1,3 +1,4 @@
export * from './stacks.utils';
export * from './message-signing';
export * from './signer/signer';
+export * from './transactions';
diff --git a/packages/stacks/src/stacks.utils.ts b/packages/stacks/src/stacks.utils.ts
index b6626f17d..cfd228500 100644
--- a/packages/stacks/src/stacks.utils.ts
+++ b/packages/stacks/src/stacks.utils.ts
@@ -2,7 +2,9 @@ import { bytesToHex } from '@noble/hashes/utils';
import { HDKey } from '@scure/bip32';
import { compressPrivateKey } from '@stacks/encryption';
import { ChainID, createStacksPrivateKey, getPublicKey } from '@stacks/transactions';
+import BigNumber from 'bignumber.js';
+import { STX_DECIMALS } from '@leather.io/constants';
import {
DerivationPathDepth,
createDescriptor,
@@ -10,7 +12,7 @@ import {
extractAddressIndexFromPath,
} from '@leather.io/crypto';
import type { NetworkModes } from '@leather.io/models';
-import { assertIsTruthy, toHexString } from '@leather.io/utils';
+import { abbreviateNumber, assertIsTruthy, microStxToStx, toHexString } from '@leather.io/utils';
export const stxDerivationWithAccount = `m/44'/5757'/0'/0/{account}`;
@@ -71,3 +73,110 @@ export function getStacksBurnAddress(chainId: ChainID): string {
return 'ST000000000000000000002AMW42H';
}
}
+
+function cleanHex(hexWithMaybePrefix: string): string {
+ if (hexWithMaybePrefix !== 'string') return hexWithMaybePrefix;
+ return hexWithMaybePrefix.startsWith('0x')
+ ? hexWithMaybePrefix.replace('0x', '')
+ : hexWithMaybePrefix;
+}
+
+export function hexToBuff(hex: string): Buffer {
+ return Buffer.from(cleanHex(hex), 'hex');
+}
+
+export const stacksValue = ({
+ value,
+ fixedDecimals = true,
+ withTicker = true,
+ abbreviate = false,
+}: {
+ value: number | string | BigNumber;
+ fixedDecimals?: boolean;
+ withTicker?: boolean;
+ abbreviate?: boolean;
+}) => {
+ const stacks = microStxToStx(value);
+ const stxAmount = stacks.toNumber();
+ return `${
+ abbreviate && stxAmount > 10000
+ ? abbreviateNumber(stxAmount)
+ : stxAmount.toLocaleString('en-US', {
+ maximumFractionDigits: fixedDecimals ? STX_DECIMALS : 3,
+ })
+ }${withTicker ? ' STX' : ''}`;
+};
+
+/**
+ * Gets the contract name of a fully qualified name of an asset.
+ *
+ * @param contractId - the source string: [principal].[contract-name] or [principal].[contract-name]::[asset-name]
+ */
+export const getStacksContractName = (contractId: string): string => {
+ if (contractId.includes('.')) {
+ const parts = contractId?.split('.');
+ if (contractId.includes('::')) {
+ return parts[1].split('::')[0];
+ }
+ return parts[1];
+ }
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'getStacksContractName: does not contain a period, does not appear to be a contractId.',
+ contractId
+ );
+ return contractId;
+};
+
+/**
+ * Gets the asset name from a a fully qualified name of an asset.
+ *
+ * @param contractId - the fully qualified name of the asset: [principal].[contract-name]::[asset-name]
+ */
+const getStacksContractAssetName = (contractId: string): string => {
+ if (!contractId.includes('::')) {
+ // eslint-disable-next-line no-console
+ console.log(
+ 'getStacksContractAssetName: does not contain "::", does not appear to be a fully qualified name of an asset.',
+ contractId
+ );
+ return contractId;
+ }
+ return contractId.split('::')[1];
+};
+
+/**
+ * Gets the parts that make up a fully qualified name of an asset.
+ *
+ * @param contractId - the fully qualified name of the asset: [principal].[contract-name]::[asset-name]
+ */
+export const getStacksContractIdStringParts = (
+ contractId: string
+): {
+ contractAddress: string;
+ contractAssetName: string;
+ contractName: string;
+} => {
+ if (!contractId.includes('.') || !contractId.includes('::')) {
+ // eslint-disable-next-line no-console
+ console.log(
+ 'getStacksContractIdStringParts: does not contain a period or "::", does not appear to be a fully qualified name of an asset.',
+ contractId
+ );
+ return {
+ contractAddress: contractId,
+ contractAssetName: contractId,
+ contractName: contractId,
+ };
+ }
+
+ const contractAddress = contractId.split('.')[0];
+ const contractAssetName = getStacksContractAssetName(contractId);
+ const contractName = getStacksContractName(contractId);
+
+ return {
+ contractAddress,
+ contractAssetName,
+ contractName,
+ };
+};
diff --git a/packages/stacks/src/transactions/generate-unsigned-tx.ts b/packages/stacks/src/transactions/generate-unsigned-tx.ts
new file mode 100644
index 000000000..6d6237f88
--- /dev/null
+++ b/packages/stacks/src/transactions/generate-unsigned-tx.ts
@@ -0,0 +1,137 @@
+import {
+ AnchorMode,
+ deserializeCV,
+ makeUnsignedContractCall,
+ makeUnsignedContractDeploy,
+ makeUnsignedSTXTokenTransfer,
+} from '@stacks/transactions';
+import BigNumber from 'bignumber.js';
+
+import { hexToBuff } from '../stacks.utils';
+import { getPostConditions } from './post-condition.utils';
+import {
+ ContractCallPayload as ConnectContractCallPayload,
+ ContractDeployPayload as ConnectContractDeployPayload,
+ STXTransferPayload as ConnectSTXTransferPayload,
+ TransactionTypes,
+} from './stacks-connect.types';
+import { isTransactionTypeSupported } from './transaction.utils';
+
+function initNonce(nonce?: number) {
+ return nonce !== undefined ? new BigNumber(nonce, 10) : undefined;
+}
+
+// Hoping we don't need this now, will confirm and remove
+//
+// This type exists to bridge the gap while @stacks/connect uses an outdated
+// version of @stacks/network
+// interface TempCorrectNetworkPackageType {
+// network?: StacksNetwork;
+// }
+
+interface GenerateUnsignedTxArgs {
+ txData: TxPayload;
+ publicKey: string;
+ fee: number | string;
+ nonce?: number;
+}
+
+// type ContractCallPayload = Omit &
+// TempCorrectNetworkPackageType;
+type GenerateUnsignedContractCallTxArgs = GenerateUnsignedTxArgs;
+
+function generateUnsignedContractCallTx(args: GenerateUnsignedContractCallTxArgs) {
+ const { txData, publicKey, nonce, fee } = args;
+ const {
+ contractName,
+ contractAddress,
+ functionName,
+ functionArgs,
+ sponsored,
+ postConditionMode,
+ postConditions,
+ network,
+ anchorMode,
+ } = txData;
+
+ const fnArgs = functionArgs.map(arg => deserializeCV(hexToBuff(arg)));
+
+ const options = {
+ contractName,
+ contractAddress,
+ functionName,
+ publicKey,
+ anchorMode: anchorMode ?? AnchorMode.Any,
+ functionArgs: fnArgs,
+ nonce: initNonce(nonce)?.toString(),
+ fee: new BigNumber(fee, 10).toString(),
+ postConditionMode: postConditionMode,
+ postConditions: getPostConditions(postConditions),
+ network,
+ sponsored,
+ };
+ return makeUnsignedContractCall(options);
+}
+
+// type ContractDeployPayload = Omit &
+// TempCorrectNetworkPackageType;
+type GenerateUnsignedContractDeployTxArgs = GenerateUnsignedTxArgs;
+
+function generateUnsignedContractDeployTx(args: GenerateUnsignedContractDeployTxArgs) {
+ const { txData, publicKey, nonce, fee } = args;
+ const { contractName, codeBody, network, postConditions, postConditionMode, anchorMode } = txData;
+ const options = {
+ contractName,
+ codeBody,
+ nonce: initNonce(nonce)?.toString(),
+ fee: new BigNumber(fee, 10)?.toString(),
+ publicKey,
+ anchorMode: anchorMode ?? AnchorMode.Any,
+ postConditionMode: postConditionMode,
+ postConditions: getPostConditions(postConditions),
+ network,
+ };
+ return makeUnsignedContractDeploy(options);
+}
+
+// type STXTransferPayload = Omit &
+// TempCorrectNetworkPackageType;
+type GenerateUnsignedStxTransferTxArgs = GenerateUnsignedTxArgs;
+
+function generateUnsignedStxTransferTx(args: GenerateUnsignedStxTransferTxArgs) {
+ const { txData, publicKey, nonce, fee } = args;
+ const { recipient, memo, amount, network, anchorMode } = txData;
+ const options = {
+ recipient,
+ memo,
+ publicKey,
+ anchorMode: anchorMode ?? AnchorMode.Any,
+ amount: new BigNumber(amount).toString(),
+ nonce: initNonce(nonce)?.toString(),
+ fee: new BigNumber(fee, 10).toString(),
+ network,
+ };
+ return makeUnsignedSTXTokenTransfer(options);
+}
+
+// export type GenerateUnsignedTransactionOptions = GenerateUnsignedTxArgs<
+// ContractCallPayload | STXTransferPayload | ContractDeployPayload
+// >;
+export type GenerateUnsignedTransactionOptions = GenerateUnsignedTxArgs<
+ ConnectContractCallPayload | ConnectSTXTransferPayload | ConnectContractDeployPayload
+>;
+export async function generateUnsignedTransaction(options: GenerateUnsignedTransactionOptions) {
+ const { txData, publicKey, nonce, fee } = options;
+ const isValid = isTransactionTypeSupported(txData.txType);
+
+ if (!isValid) throw new Error(`Invalid Transaction Type: ${txData.txType}`);
+
+ switch (txData.txType) {
+ case TransactionTypes.STXTransfer:
+ return generateUnsignedStxTransferTx({ txData, publicKey, nonce, fee });
+ case TransactionTypes.ContractCall:
+ return generateUnsignedContractCallTx({ txData, publicKey, nonce, fee });
+ case TransactionTypes.ContractDeploy:
+ return generateUnsignedContractDeployTx({ txData, publicKey, nonce, fee });
+ }
+}
diff --git a/packages/stacks/src/transactions/index.ts b/packages/stacks/src/transactions/index.ts
new file mode 100644
index 000000000..78c468110
--- /dev/null
+++ b/packages/stacks/src/transactions/index.ts
@@ -0,0 +1,4 @@
+export * from './generate-unsigned-tx';
+export * from './post-condition.utils';
+export * from './stacks-connect.types';
+export * from './transaction.utils';
diff --git a/packages/stacks/src/transactions/post-condition.utils.ts b/packages/stacks/src/transactions/post-condition.utils.ts
new file mode 100644
index 000000000..f9df28b97
--- /dev/null
+++ b/packages/stacks/src/transactions/post-condition.utils.ts
@@ -0,0 +1,160 @@
+import { hexToBytes } from '@noble/hashes/utils';
+import {
+ BytesReader,
+ FungibleConditionCode,
+ FungiblePostCondition,
+ NonFungibleConditionCode,
+ NonFungiblePostCondition,
+ PostCondition,
+ PostConditionType,
+ STXPostCondition,
+ addressToString,
+ deserializePostCondition,
+ parsePrincipalString,
+} from '@stacks/transactions';
+
+import { stacksValue } from '../stacks.utils';
+
+export function postConditionFromString(postCondition: string): PostCondition {
+ const reader = new BytesReader(hexToBytes(postCondition));
+ return deserializePostCondition(reader);
+}
+
+export const getIconStringFromPostCondition = (
+ pc: STXPostCondition | FungiblePostCondition | NonFungiblePostCondition
+) => {
+ if (pc.conditionType === PostConditionType.Fungible)
+ return `${addressToString(pc.assetInfo.address)}.${pc.assetInfo.contractName}.${
+ pc.assetInfo.assetName.content
+ }`;
+ if (pc.conditionType === PostConditionType.STX) return 'STX';
+ return pc.assetInfo.assetName.content;
+};
+
+export const getAmountFromPostCondition = (
+ pc: STXPostCondition | FungiblePostCondition | NonFungiblePostCondition
+) => {
+ if (pc.conditionType === PostConditionType.Fungible) return pc.amount.toString();
+ if (pc.conditionType === PostConditionType.STX)
+ return stacksValue({ value: pc.amount.toString(), withTicker: false });
+ return '';
+};
+
+export const getSymbolFromPostCondition = (
+ pc: STXPostCondition | FungiblePostCondition | NonFungiblePostCondition
+) => {
+ if ('assetInfo' in pc) {
+ return pc.assetInfo.assetName.content.slice(0, 3).toUpperCase();
+ }
+ return 'STX';
+};
+
+export const getNameFromPostCondition = (
+ pc: STXPostCondition | FungiblePostCondition | NonFungiblePostCondition
+) => {
+ if ('assetInfo' in pc) {
+ return pc.assetInfo.assetName.content;
+ }
+ return 'STX';
+};
+
+export function getPostConditionCodeMessage(
+ code: FungibleConditionCode | NonFungibleConditionCode,
+ isSender: boolean
+) {
+ const sender = isSender ? 'You' : 'The contract';
+ switch (code) {
+ case FungibleConditionCode.Equal:
+ return `${sender} will transfer exactly`;
+
+ case FungibleConditionCode.Greater:
+ return `${sender} will transfer more than`;
+
+ case FungibleConditionCode.GreaterEqual:
+ return `${sender} will transfer at least`;
+
+ case FungibleConditionCode.Less:
+ return `${sender} will transfer less than`;
+
+ case FungibleConditionCode.LessEqual:
+ return `${sender} will transfer at most`;
+
+ case NonFungibleConditionCode.Sends:
+ return `${sender} will transfer`;
+
+ case NonFungibleConditionCode.DoesNotSend:
+ return `${sender} will keep or receive`;
+ }
+}
+
+/**
+ * This method will update a post conditions principal
+ * value to the current address principal if and only if
+ * the `stxAddress` value from the original tx payload
+ * matches the address in the original post condition.
+ *
+ * This is used when a user might switch accounts during
+ * the signing process. One can assume that if the post
+ * condition has the principal set to the same value as the
+ * `stxAddress` value, it should be updated when they switch
+ * accounts.
+ */
+export function handlePostConditions(
+ postConditions: (PostCondition | string)[],
+ payloadAddress: string,
+ currentAddress: string
+): PostCondition[] {
+ const payloadPrincipal = parsePrincipalString(payloadAddress);
+ const currentAddressPrincipal = parsePrincipalString(currentAddress);
+
+ return postConditions.map(postCondition => {
+ const formattedPostCondition = getPostCondition(postCondition);
+ // if it's a contract principal, do nothing
+ if ('contractName' in formattedPostCondition.principal) return formattedPostCondition;
+ const { principal, ...payload } = formattedPostCondition;
+ const sameType = payloadPrincipal.address.type === principal.address.type;
+ const sameHash = payloadPrincipal.address.hash160 === principal.address.hash160;
+ const isOriginatorAddress = sameHash && sameType;
+ return {
+ ...payload,
+ principal: isOriginatorAddress ? currentAddressPrincipal : principal,
+ };
+ });
+}
+
+export function getPostCondition(postCondition: string | PostCondition): PostCondition {
+ return typeof postCondition === 'string' ? postConditionFromString(postCondition) : postCondition;
+}
+
+export function getPostConditions(
+ postConditions?: (string | PostCondition)[]
+): PostCondition[] | undefined {
+ return postConditions?.map(getPostCondition);
+}
+
+const getTitleFromConditionCode = (code: FungibleConditionCode | NonFungibleConditionCode) => {
+ switch (code) {
+ case FungibleConditionCode.Equal:
+ return 'will transfer exactly';
+ case FungibleConditionCode.Greater:
+ return 'will transfer more than';
+ case FungibleConditionCode.GreaterEqual:
+ return 'will transfer equal to or greater than';
+ case FungibleConditionCode.Less:
+ return 'will transfer less than';
+ case FungibleConditionCode.LessEqual:
+ return 'will transfer less than or equal to';
+ case NonFungibleConditionCode.Sends:
+ return 'will transfer';
+ case NonFungibleConditionCode.DoesNotSend:
+ return 'will keep';
+ default:
+ return '';
+ }
+};
+
+export const getPostConditionTitle = (
+ pc: STXPostCondition | FungiblePostCondition | NonFungiblePostCondition
+) => {
+ return getTitleFromConditionCode(pc.conditionCode) || '';
+};
diff --git a/packages/stacks/src/transactions/stacks-connect.types.ts b/packages/stacks/src/transactions/stacks-connect.types.ts
new file mode 100644
index 000000000..1ff7aec29
--- /dev/null
+++ b/packages/stacks/src/transactions/stacks-connect.types.ts
@@ -0,0 +1,142 @@
+import { StacksNetwork } from '@stacks/network';
+import {
+ AnchorMode,
+ ClarityValue,
+ PostCondition,
+ PostConditionMode,
+ StacksTransaction,
+} from '@stacks/transactions';
+
+// Types are copied from @stacks/connect to avoid installing the package
+
+export interface TxBase {
+ // This is from type UserSession
+ appDetails?: {
+ /** A human-readable name for your application */
+ name: string;
+ /** A full URL that resolves to an image icon for your application */
+ icon: string;
+ };
+ postConditionMode?: PostConditionMode;
+ postConditions?: (string | PostCondition)[];
+ network?: StacksNetwork;
+ anchorMode?: AnchorMode;
+ attachment?: string;
+ fee?: number | string;
+ /**
+ * Provide the Hiro Wallet with a suggested account to sign this transaction with.
+ * This is set by default if a `userSession` option is provided.
+ */
+ stxAddress?: string;
+ /** @deprecated `unused - only included for compatibility with @stacks/transactions` */
+ senderKey?: string;
+ /** @deprecated `unused - only included for compatibility with @stacks/transactions` */
+ nonce?: number;
+}
+export interface SponsoredFinishedTxPayload {
+ txRaw: string;
+}
+export interface SponsoredFinishedTxData extends SponsoredFinishedTxPayload {
+ stacksTransaction: StacksTransaction;
+}
+export interface FinishedTxPayload extends SponsoredFinishedTxPayload {
+ txId: string;
+}
+export interface FinishedTxData extends FinishedTxPayload {
+ stacksTransaction: StacksTransaction;
+}
+
+export enum TransactionTypes {
+ ContractCall = 'contract_call',
+ ContractDeploy = 'smart_contract',
+ STXTransfer = 'token_transfer',
+}
+
+/**
+ * Contract Call
+ */
+export enum ContractCallArgumentType {
+ BUFFER = 'buffer',
+ UINT = 'uint',
+ INT = 'int',
+ PRINCIPAL = 'principal',
+ BOOL = 'bool',
+}
+export interface ContractCallBase extends TxBase {
+ contractAddress: string;
+ contractName: string;
+ functionName: string;
+ functionArgs: (string | ClarityValue)[];
+}
+
+export type SponsoredFinished = (data: SponsoredFinishedTxData) => void;
+export type Finished = (data: FinishedTxData) => void;
+export type Canceled = () => void;
+export interface SponsoredOptionsBase extends TxBase {
+ sponsored: true;
+ onFinish?: SponsoredFinished;
+ onCancel?: Canceled;
+}
+export interface RegularOptionsBase extends TxBase {
+ sponsored?: false;
+ onFinish?: Finished;
+ onCancel?: Canceled;
+}
+export type ContractCallRegularOptions = ContractCallBase & RegularOptionsBase;
+export type ContractCallSponsoredOptions = ContractCallBase & SponsoredOptionsBase;
+export type ContractCallOptions = ContractCallRegularOptions | ContractCallSponsoredOptions;
+export interface ContractCallArgument {
+ type: ContractCallArgumentType;
+ value: string;
+}
+
+export interface ContractCallPayload extends ContractCallBase {
+ txType: TransactionTypes.ContractCall;
+ publicKey: string;
+ functionArgs: string[];
+ sponsored?: boolean;
+}
+
+/**
+ * Contract Deploy
+ */
+export interface ContractDeployBase extends TxBase {
+ contractName: string;
+ codeBody: string;
+}
+export type ContractDeployRegularOptions = ContractDeployBase & RegularOptionsBase;
+export type ContractDeploySponsoredOptions = ContractDeployBase & SponsoredOptionsBase;
+export type ContractDeployOptions = ContractDeployRegularOptions | ContractDeploySponsoredOptions;
+export interface ContractDeployPayload extends ContractDeployBase {
+ publicKey: string;
+ txType: TransactionTypes.ContractDeploy;
+ sponsored?: boolean;
+}
+
+/**
+ * STX Transfer
+ */
+export interface STXTransferBase extends TxBase {
+ recipient: string;
+ amount: bigint | string;
+ memo?: string;
+}
+export type STXTransferRegularOptions = STXTransferBase & RegularOptionsBase;
+export type STXTransferSponsoredOptions = STXTransferBase & SponsoredOptionsBase;
+export type STXTransferOptions = STXTransferRegularOptions | STXTransferSponsoredOptions;
+export interface STXTransferPayload extends STXTransferBase {
+ publicKey: string;
+ txType: TransactionTypes.STXTransfer;
+ amount: string;
+ sponsored?: boolean;
+}
+
+/**
+ * Transaction Popup
+ */
+export type TransactionOptions = ContractCallOptions | ContractDeployOptions | STXTransferOptions;
+export type TransactionPayload = ContractCallPayload | ContractDeployPayload | STXTransferPayload;
+export interface TransactionPopup {
+ token: string;
+ options: TransactionOptions;
+}
diff --git a/packages/stacks/src/transactions/transaction.utils.ts b/packages/stacks/src/transactions/transaction.utils.ts
new file mode 100644
index 000000000..29052f7ba
--- /dev/null
+++ b/packages/stacks/src/transactions/transaction.utils.ts
@@ -0,0 +1,134 @@
+import { bytesToHex } from '@noble/hashes/utils';
+import {
+ CoinbaseTransaction,
+ TransactionEventFungibleAsset,
+} from '@stacks/stacks-blockchain-api-types';
+import {
+ AddressHashMode,
+ AuthType,
+ StacksTransaction,
+ TransactionVersion,
+ addressFromVersionHash,
+ addressHashModeToVersion,
+ addressToString,
+} from '@stacks/transactions';
+import { BigNumber } from 'bignumber.js';
+
+import { StacksTx, StacksTxStatus } from '@leather.io/models';
+import { truncateMiddle } from '@leather.io/utils';
+
+import { getStacksContractName, stacksValue } from '../stacks.utils';
+import { TransactionTypes } from './stacks-connect.types';
+
+export const statusFromTx = (tx: StacksTx): StacksTxStatus => {
+ const { tx_status } = tx;
+ if (tx_status === 'pending') return 'pending';
+ if (tx_status === 'success') return 'success';
+ return 'failed';
+};
+
+export const stacksTransactionToHex = (transaction: StacksTransaction) =>
+ `0x${bytesToHex(transaction.serialize())}`;
+
+export const getTxCaption = (transaction: StacksTx) => {
+ switch (transaction.tx_type) {
+ case 'smart_contract':
+ return truncateMiddle(transaction.smart_contract.contract_id.split('.')[0], 4);
+ case 'contract_call':
+ return transaction.contract_call.contract_id.split('.')[1];
+ case 'token_transfer':
+ case 'coinbase':
+ case 'poison_microblock':
+ return truncateMiddle(transaction.tx_id, 4);
+ default:
+ return null;
+ }
+};
+
+const getAssetTransfer = (tx: StacksTx): TransactionEventFungibleAsset | null => {
+ if (tx.tx_type !== 'contract_call') return null;
+ if (tx.tx_status !== 'success') return null;
+ const transfer = tx.events.find(event => event.event_type === 'fungible_token_asset');
+ if (transfer?.event_type !== 'fungible_token_asset') return null;
+ return transfer;
+};
+
+export const getTxValue = (tx: StacksTx, isOriginator: boolean): number | string | null => {
+ if (tx.tx_type === 'token_transfer') {
+ return `${isOriginator ? '-' : ''}${stacksValue({
+ value: tx.token_transfer.amount,
+ withTicker: false,
+ })}`;
+ }
+ const transfer = getAssetTransfer(tx);
+ if (transfer) return new BigNumber(transfer.asset.amount).toFormat();
+ return null;
+};
+
+export const getTxTitle = (tx: StacksTx) => {
+ switch (tx.tx_type) {
+ case 'token_transfer':
+ return 'Stacks';
+ case 'contract_call':
+ return tx.contract_call.function_name;
+ case 'smart_contract':
+ return getStacksContractName(tx.smart_contract.contract_id);
+ case 'coinbase':
+ return `Coinbase ${(tx as CoinbaseTransaction).block_height}`;
+ case 'poison_microblock':
+ return 'Poison Microblock';
+ default:
+ return '';
+ }
+};
+
+export const calculateTokenTransferAmount = (
+ decimals: number,
+ amount: number | string | BigNumber
+) => {
+ return new BigNumber(amount).shiftedBy(-decimals);
+};
+
+export function isTransactionTypeSupported(txType: TransactionTypes) {
+ return (
+ txType === TransactionTypes.STXTransfer ||
+ txType === TransactionTypes.ContractCall ||
+ txType === TransactionTypes.ContractDeploy
+ );
+}
+
+export function isTxSponsored(tx: StacksTransaction) {
+ return tx.auth.authType === AuthType.Sponsored;
+}
+
+function getAddressFromPublicKeyHash(
+ publicKeyHash: Buffer,
+ hashMode: AddressHashMode,
+ transactionVersion: TransactionVersion
+): string {
+ const addrVer = addressHashModeToVersion(hashMode, transactionVersion);
+ if (publicKeyHash.length !== 20) {
+ throw new Error('expected 20-byte pubkeyhash');
+ }
+ const addr = addressFromVersionHash(addrVer, publicKeyHash.toString('hex'));
+ return addressToString(addr);
+}
+
+export function getTxSenderAddress(tx: StacksTransaction): string | undefined {
+ if (!tx?.auth?.spendingCondition?.signer) return;
+ const txSender = getAddressFromPublicKeyHash(
+ Buffer.from(tx.auth.spendingCondition.signer, 'hex'),
+ tx.auth.spendingCondition.hashMode as number,
+ tx.version
+ );
+ return txSender;
+}
+
+export function isPendingTx(tx: StacksTx) {
+ return tx.tx_status === 'pending';
+}
+
+export enum StacksTransactionActionType {
+ Cancel = 'cancel',
+ IncreaseFee = 'increase-fee',
+}
diff --git a/packages/ui/src/components/item-layout/item-layout.native.tsx b/packages/ui/src/components/item-layout/item-layout.native.tsx
index 6f72c2cde..ad15a5fde 100644
--- a/packages/ui/src/components/item-layout/item-layout.native.tsx
+++ b/packages/ui/src/components/item-layout/item-layout.native.tsx
@@ -34,7 +34,7 @@ export function ItemLayout({
)}
{hasRightElement && (
-
+
{isValidElement(titleRight) ? titleRight : {titleRight}}
{captionRight && isValidElement(captionRight) && captionRight}
{captionRight && isString(captionRight) && (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bab053d23..be968ba2c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -174,6 +174,9 @@ importers:
'@stacks/common':
specifier: 6.13.0
version: 6.13.0
+ '@stacks/network':
+ specifier: 6.13.0
+ version: 6.13.0
'@stacks/stacks-blockchain-api-types':
specifier: 7.8.2
version: 7.8.2
@@ -288,6 +291,9 @@ importers:
react:
specifier: 18.2.0
version: 18.2.0
+ react-async-hook:
+ specifier: 4.0.0
+ version: 4.0.0(react@18.2.0)
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
@@ -885,6 +891,9 @@ importers:
packages/stacks:
dependencies:
+ '@leather.io/constants':
+ specifier: workspace:*
+ version: link:../constants
'@leather.io/crypto':
specifier: workspace:*
version: link:../crypto
@@ -903,10 +912,19 @@ importers:
'@stacks/encryption':
specifier: 6.16.1
version: 6.16.1
+ '@stacks/network':
+ specifier: 6.13.0
+ version: 6.13.0
'@stacks/transactions':
specifier: 6.17.0
version: 6.17.0
+ bignumber.js:
+ specifier: 9.1.2
+ version: 9.1.2
devDependencies:
+ '@stacks/stacks-blockchain-api-types':
+ specifier: 7.8.2
+ version: 7.8.2
tsup:
specifier: 8.1.0
version: 8.1.0(@microsoft/api-extractor@7.47.6(@types/node@22.7.8))(@swc/core@1.7.39)(postcss@8.4.47)(typescript@5.5.4)
@@ -2617,7 +2635,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {'0': node >=0.10.0}
+ engines: {node: '>=0.10.0'}
'@expo/cli@0.18.28':
resolution: {integrity: sha512-fvbVPId6s6etindzP6Nzos/CS1NurMVy4JKozjebArHr63tBid5i/UY5Pp+4wTCAM20gB2SjRdwcwoL6HFC4Iw==}
@@ -2946,6 +2964,7 @@ packages:
'@ls-lint/ls-lint@2.2.3':
resolution: {integrity: sha512-ekM12jNm/7O2I/hsRv9HvYkRdfrHpiV1epVuI2NP+eTIcEgdIdKkKCs9KgQydu/8R5YXTov9aHdOgplmCHLupw==}
+ cpu: [x64, arm64, s390x]
os: [darwin, linux, win32]
hasBin: true
@@ -10670,6 +10689,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-async-hook@4.0.0:
+ resolution: {integrity: sha512-97lgjFkOcHCTYSrsKBpsXg3iVWM0LnzedB749iP76sb3/8Ouu4nHIkCLEOrQWHVYqrYxjF05NN6GHoXWFkB3Kw==}
+ engines: {node: '>=8', npm: '>=5'}
+ peerDependencies:
+ react: '>=16.8'
+
react-colorful@5.6.1:
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
peerDependencies:
@@ -17481,7 +17506,7 @@ snapshots:
'@stacks/auth': 6.17.0
'@stacks/common': 6.13.0
'@stacks/encryption': 6.16.1
- '@stacks/network': 6.17.0
+ '@stacks/network': 6.13.0
'@stacks/profile': 6.17.0
'@stacks/storage': 6.17.0
'@stacks/transactions': 6.17.0
@@ -25487,6 +25512,10 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ react-async-hook@4.0.0(react@18.2.0):
+ dependencies:
+ react: 18.2.0
+
react-colorful@5.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0