Skip to content

Commit

Permalink
feat: add brc-20 send flow, closes leather-io#3669
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo committed Jun 1, 2023
1 parent 3805c77 commit f12322b
Show file tree
Hide file tree
Showing 34 changed files with 1,204 additions and 92 deletions.
2 changes: 1 addition & 1 deletion src/app/common/hooks/use-explorer-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useCurrentNetworkState } from '@app/store/networks/networks.hooks';

import { openInNewTab } from '../utils/open-in-new-tab';

interface HandleOpenTxLinkArgs {
export interface HandleOpenTxLinkArgs {
blockchain: Blockchains;
suffix?: string;
txid: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ import {
useCurrentBitcoinNativeSegwitAddressIndexPublicKeychain,
} from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';

interface GenerateBitcoinTxValues {
interface GenerateNativeSegwitTxValues {
amount: Money;
recipient: string;
}

export function useGenerateSignedBitcoinTx() {
export function useGenerateSignedNativeSegwitTx() {
const currentAccountBtcAddress = useCurrentAccountNativeSegwitAddressIndexZero();
const { data: utxos } = useSpendableCurrentNativeSegwitAccountUtxos();
const currentAddressIndexKeychain = useCurrentBitcoinNativeSegwitAddressIndexPublicKeychain();
Expand All @@ -30,7 +29,7 @@ export function useGenerateSignedBitcoinTx() {
const networkMode = useBitcoinScureLibNetworkConfig();

return useCallback(
(values: GenerateBitcoinTxValues, feeRate: number) => {
(values: GenerateNativeSegwitTxValues, feeRate: number) => {
if (!utxos) return;
if (!feeRate) return;
if (!createSigner) return;
Expand Down
11 changes: 11 additions & 0 deletions src/app/common/validation/forms/amount-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,14 @@ export function stacksFungibleTokenAmountValidator(balance: Money) {
},
});
}

export function brc20TokenAmountValidator(balance: Money) {
const { amount } = balance;
return amountValidator().test({
message: formatInsufficientBalanceError(balance, sum => sum.amount.toString()),
test(value) {
if (!isNumber(value) || !amount) return false;
return new BigNumber(value).isLessThanOrEqualTo(amount);
},
});
}
1 change: 1 addition & 0 deletions src/app/components/bitcoin-fees-list/bitcoin-fees-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function BitcoinFeesList({

const validateTotalSpend = useCallback(
(feeValue: number) => {
if (amount.symbol !== 'BTC') return;
const feeAsMoney = createMoney(feeValue, 'BTC');
const totalSpend = sumMoney([amount, feeAsMoney]);
if (totalSpend.amount.isGreaterThan(balance.amount)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ import { Brc20TokenAssetItemLayout } from './brc20-token-asset-item.layout';

interface Brc20TokenAssetItemProps extends BoxProps {
token: Brc20Token;
isPressable?: boolean;
}
export const Brc20TokenAssetItem = forwardRefWithAs((props: Brc20TokenAssetItemProps, ref) => {
const { token, ...rest } = props;
const { token, isPressable, ...rest } = props;

return (
<Brc20TokenAssetItemLayout
balance={createMoney(Number(token.overall_balance), token.tick, 0)}
caption="BRC-20"
ref={ref}
title={token.tick}
isPressable={isPressable}
{...rest}
/>
);
Expand Down
1 change: 1 addition & 0 deletions src/app/components/info-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function InfoLabel({ children, title, ...rest }: InfoLabelProps) {
px="base"
py="base-tight"
spacing="tight"
width="100%"
>
<Box
_hover={{ cursor: 'pointer' }}
Expand Down
12 changes: 12 additions & 0 deletions src/app/components/status-pending.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function StatusPending() {
return (
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#F59300',
}}
/>
);
}
12 changes: 12 additions & 0 deletions src/app/components/status-ready.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function StatusReady() {
return (
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#23A978',
}}
/>
);
}
3 changes: 3 additions & 0 deletions src/app/features/balances-list/balances-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useStacksFungibleTokenAssetBalancesAnchoredWithMetadata } from '@app/qu
import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';

import { Collectibles } from '../collectibles/collectibles';
import { PendingBrc20TransferList } from '../pending-brc-20-transfers/pending-brc-20-transfers';
import { BitcoinFungibleTokenAssetList } from './components/bitcoin-fungible-tokens-asset-list';
import { StacksFungibleTokenAssetList } from './components/stacks-fungible-token-asset-list';

Expand Down Expand Up @@ -81,6 +82,8 @@ export function BalancesList({ address, ...props }: BalancesListProps) {
ledger: null,
})}

<PendingBrc20TransferList />

<Collectibles />
</Stack>
);
Expand Down
138 changes: 138 additions & 0 deletions src/app/features/pending-brc-20-transfers/pending-brc-20-transfers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useNavigate } from 'react-router-dom';

import { Box, Flex, Stack, Text } from '@stacks/ui';

import { RouteUrls } from '@shared/route-urls';
import { noop } from '@shared/utils';

import { CaptionDotSeparator } from '@app/components/caption-dot-separator';
import { usePressable } from '@app/components/item-hover';
import { Flag } from '@app/components/layout/flag';
import { StatusPending } from '@app/components/status-pending';
import { StatusReady } from '@app/components/status-ready';
import { Tooltip } from '@app/components/tooltip';
import { Caption } from '@app/components/typography';
import { useCheckOrderStatuses } from '@app/query/bitcoin/ordinals/brc20/use-check-order-status';
import { convertInscriptionToSupportedInscriptionType } from '@app/query/bitcoin/ordinals/inscription.hooks';
import { fetchInscripionById } from '@app/query/bitcoin/ordinals/use-inscription-by-id';
import { useOrdinalsbotClient } from '@app/query/bitcoin/ordinalsbot-client';
import {
OrdinalsbotInscriptionStatus,
PendingBrc20Transfer,
usePendingBrc20Transfers,
} from '@app/store/ordinals/ordinals.slice';

function StatusIcon({ status }: { status: OrdinalsbotInscriptionStatus }) {
switch (status) {
case 'pending':
return <StatusPending />;
case 'paid':
return <StatusPending />;
case 'waiting-for-indexer':
return <StatusPending />;
case 'ready':
return <StatusReady />;
default:
return null;
}
}
function StatusLabel({ status }: { status: OrdinalsbotInscriptionStatus }) {
switch (status) {
case 'pending':
return <>Paying for transfer inscription…</>;
case 'paid':
return (
<Tooltip label="Your funds have been received. Your inscription will be available shortly.">
<Text>Creating transfer inscription…</Text>
</Tooltip>
);
case 'waiting-for-indexer':
return (
<Tooltip label="Inscription complete, awaiting metadata">
<Text>Receiving transfer inscription…</Text>
</Tooltip>
);
case 'ready':
return <Text>Ready to transfer</Text>;
default:
return null;
}
}

export function PendingBrc20TransferList() {
const transferOrders = usePendingBrc20Transfers();

useCheckOrderStatuses(
transferOrders.filter(order => order.status !== 'ready').map(order => order.id)
);

if (!transferOrders.length) return null;

return (
<Flex flexDirection="column" justifyContent="space-between" flex={1} my="base-loose">
<Flex columnGap="8px">
<Caption>Pending BRC-20 transfers</Caption>
</Flex>
<Stack mt="tight">
{transferOrders.map(order => (
<PendingBrcTransfer key={order.id} order={order} />
))}
</Stack>
</Flex>
);
}

interface PendingBrcTransferProps {
order: PendingBrc20Transfer;
}
function PendingBrcTransfer({ order }: PendingBrcTransferProps) {
const [component, bind] = usePressable(order.status === 'ready');
const navigate = useNavigate();
const ordinalsbotClient = useOrdinalsbotClient();

return (
<Box
key={order.id}
my="base-tight"
onClick={
order.status === 'ready'
? async () => {
// Really inefficient, find way to not have to refetch data
const { data: orderInfo } = await ordinalsbotClient.orderStatus(order.id);
const { data: inscription } = await fetchInscripionById(
(orderInfo as any).files[0].tx?.inscription
);
navigate(RouteUrls.SendOrdinalInscription, {
state: {
inscription: convertInscriptionToSupportedInscriptionType({
...inscription,
addressIndex: 0,
}),
},
});
}
: noop
}
{...(order.status === 'ready' ? bind : {})}
>
<Text>
{order.amount} {order.tick}
</Text>
<Stack isInline width="100%" mt="tight">
<CaptionDotSeparator>
<Flex as={Caption}>
<Flag
ml="tight"
align="middle"
spacing="tight"
img={<StatusIcon status={order.status} />}
>
<StatusLabel status={order.status} />
</Flag>
</Flex>
</CaptionDotSeparator>
</Stack>
{component}
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createMoney } from '@shared/models/money.model';
import { RouteUrls } from '@shared/route-urls';
import { noop } from '@shared/utils';

import { useGenerateSignedBitcoinTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx';
import { useGenerateSignedNativeSegwitTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx';
import { useWalletType } from '@app/common/use-wallet-type';
import {
BitcoinFeesList,
Expand All @@ -34,7 +34,7 @@ export function RpcSendTransferChooseFee() {
const { address, amountAsMoney } = useRpcSendTransferFeeState();
const navigate = useNavigate();
const { whenWallet } = useWalletType();
const generateTx = useGenerateSignedBitcoinTx();
const generateTx = useGenerateSignedNativeSegwitTx();
const { feesList, isLoading } = useBitcoinFeesList({
amount: Number(amountAsMoney.amount),
recipient: address,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import { useRouteHeader } from '@app/common/hooks/use-route-header';
import { useAllTransferableCryptoAssetBalances } from '@app/common/hooks/use-transferable-asset-balances.hooks';
import { useWalletType } from '@app/common/use-wallet-type';
import { Header } from '@app/components/header';
import { useBrc20TokensByAddressQuery } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query';
import { useCurrentAccountTaprootAddressIndexZeroPayment } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';

import { ChooseCryptoAssetLayout } from './components/choose-crypto-asset.layout';
import { CryptoAssetList } from './components/crypto-asset-list';

export function ChooseCryptoAsset() {
const navigate = useNavigate();
const allTransferableCryptoAssetBalances = useAllTransferableCryptoAssetBalances();

const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootAddressIndexZeroPayment();
const { data: brc20Tokens = [] } = useBrc20TokensByAddressQuery(bitcoinAddressTaproot);

const { whenWallet } = useWalletType();

useRouteHeader(<Header hideActions onClose={() => navigate(RouteUrls.Home)} title=" " />);
Expand All @@ -26,6 +32,7 @@ export function ChooseCryptoAsset() {
software: true,
})
)}
brc20Tokens={brc20Tokens}
/>
</ChooseCryptoAssetLayout>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import { useNavigate } from 'react-router-dom';

import type { AllTransferableCryptoAssetBalances } from '@shared/models/crypto-asset-balance.model';
import { RouteUrls } from '@shared/route-urls';

import { Brc20TokenAssetItem } from '@app/components/crypto-assets/bitcoin/brc20-token-asset/brc20-token-asset-item';
import { Brc20Token } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query';

import { CryptoAssetListItem } from './crypto-asset-list-item';
import { CryptoAssetListLayout } from './crypto-asset-list.layout';

interface CryptoAssetListProps {
cryptoAssetBalances: AllTransferableCryptoAssetBalances[];
brc20Tokens: Brc20Token[];
}
export function CryptoAssetList({ cryptoAssetBalances }: CryptoAssetListProps) {
export function CryptoAssetList({ cryptoAssetBalances, brc20Tokens }: CryptoAssetListProps) {
const navigate = useNavigate();

function navigateToBrc20SendForm(token: Brc20Token) {
const { tick, available_balance } = token;
navigate(RouteUrls.SendBrc20SendForm.replace(':ticker', tick), {
state: { balance: available_balance, tick },
});
}

return (
<CryptoAssetListLayout>
{cryptoAssetBalances.map(assetBalance => (
<CryptoAssetListItem assetBalance={assetBalance} key={assetBalance.asset.name} />
))}
{brc20Tokens.map(token => (
<Brc20TokenAssetItem
key={token.tick}
token={token}
isPressable={true}
onClick={() => navigateToBrc20SendForm(token)}
/>
))}
</CryptoAssetListLayout>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { InfoCard, InfoCardRow, InfoCardSeparator } from '@app/components/info-c
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { PrimaryButton } from '@app/components/primary-button';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/use-current-account-native-segwit-utxos';
import { useAppDispatch } from '@app/store';
import { inscriptionSent } from '@app/store/ordinals/ordinals.slice';

import { InscriptionPreviewCard } from '../../../components/inscription-preview-card/inscription-preview-card';
import { useBitcoinBroadcastTransaction } from '../../../query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
Expand All @@ -32,7 +34,7 @@ function useSendInscriptionReviewState() {
export function SendInscriptionReview() {
const analytics = useAnalytics();
const navigate = useNavigate();

const dispatch = useAppDispatch();
const { arrivesIn, signedTx, recipient, fee } = useSendInscriptionReviewState();

const { inscription } = useSendInscriptionState();
Expand All @@ -47,7 +49,8 @@ export function SendInscriptionReview() {
async onSuccess(txId: string) {
void analytics.track('broadcast_ordinal_transaction');
await refetch();

// Might be a BRC-20 transfer, so we want to remove it from the pending
dispatch(inscriptionSent({ inscriptionId: inscription.id }));
navigate(RouteUrls.SendOrdinalInscriptionSent, {
state: {
inscription,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { Caption } from '@app/components/typography';

import { PreviewButton } from './preview-button';

export function FormFooter(props: { balance: Money }) {
const { balance } = props;
const balanceTooltipLabel =
'Amount that is immediately available for use after taking into account any pending transactions or holds placed on your account by the protocol.';
export function FormFooter(props: { balance: Money; balanceTooltipLabel?: string }) {
const {
balance,
balanceTooltipLabel = 'Amount that is immediately available for use after taking into account any pending transactions or holds placed on your account by the protocol.',
} = props;

return (
<Box
bg={color('bg')}
Expand Down
Loading

0 comments on commit f12322b

Please sign in to comment.