refactor: improve state, errors, etc

This commit is contained in:
Thomas Osmonson
2021-05-25 14:24:11 -05:00
parent a723e73ccd
commit 6b7aa2cc53
74 changed files with 1372 additions and 1193 deletions

16
public/html/devtool.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html class="mode__devtool">
<head>
<meta charset="UTF-8">
<title><%= htmlWebpackPlugin.options.title %> Devtools</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/assets/connect-logo/Stacks128w.png"/>
<link href="/assets/base.css" rel="stylesheet"/>
<script defer src="devtools.js"></script>
</body>
</head>
<body>
<div id="actions-root"></div>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import { ChainID } from '@stacks/transactions';
export const gaiaUrl = 'https://hub.blockstack.org';
export const transition = 'all .2s cubic-bezier(.215,.61,.355,1)';
@@ -31,3 +33,36 @@ export const SIP_010 = {
trait: 'ft-trait',
},
};
export interface Network {
url: string;
name: string;
chainId: ChainID;
}
export interface Networks {
[key: string]: Network;
}
export const defaultNetworks: Networks = {
mainnet: {
url: 'https://stacks-node-api.mainnet.stacks.co',
name: 'Mainnet',
chainId: ChainID.Mainnet,
},
testnet: {
url: 'https://stacks-node-api.testnet.stacks.co',
name: 'Testnet',
chainId: ChainID.Testnet,
},
regtest: {
url: 'https://stacks-node-api.regtest.stacks.co',
name: 'Regtest',
chainId: ChainID.Testnet,
},
localnet: {
url: 'http://localhost:3999',
name: 'Localnet',
chainId: ChainID.Testnet,
},
} as const;

View File

@@ -1,40 +0,0 @@
import { CallbackInterface, useRecoilCallback } from 'recoil';
import { postConditionsHasSetStore, transactionPayloadStore } from '@store/transaction';
import { walletState } from '@store/wallet';
import { currentAccountIndexStore } from '@store/accounts';
import { getStxAddress } from '@stacks/wallet-sdk';
function accountSwitchCallback({ snapshot, set }: CallbackInterface) {
return async () => {
const payload = await snapshot.getPromise(transactionPayloadStore);
if (!payload?.stxAddress || !payload.network) return;
const wallet = await snapshot.getPromise(walletState);
if (!wallet) return;
const transactionVersion = payload.network.version;
let foundIndex: number | undefined = undefined;
wallet.accounts.forEach((account, index) => {
const address = getStxAddress({ account, transactionVersion });
if (address === payload.stxAddress) {
foundIndex = index;
}
});
if (foundIndex !== undefined) {
console.debug('switching to index', foundIndex);
set(currentAccountIndexStore, foundIndex);
set(postConditionsHasSetStore, false);
} else {
console.warn(
'No account matches the STX address provided in transaction request:',
payload.stxAddress
);
}
};
}
/**
* Apps can specify a `stxAddress` in a transaction request.
* If the user has a matching account, use that account by default.
*/
export function useAccountSwitchCallback() {
return useRecoilCallback(accountSwitchCallback, []);
}

View File

@@ -1,39 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { isUnauthorizedTransactionStore, requestTokenStore } from '@store/transaction';
import { useLocation } from 'react-router-dom';
import { getRequestOrigin, StorageKey } from 'storage';
import { walletState } from '@store/wallet';
import { verifyTxRequest } from '@common/transaction-utils';
export function useDecodeRequestCallback() {
const location = useLocation();
return useRecoilCallback(
({ set, snapshot }) =>
async () => {
const urlParams = new URLSearchParams(location.search);
const requestToken = urlParams.get('request');
if (!requestToken) {
throw 'Invalid transaction request parameter';
}
const wallet = await snapshot.getPromise(walletState);
const origin = getRequestOrigin(StorageKey.transactionRequests, requestToken);
if (!wallet || !origin) return;
try {
// This function throws if tx is invalid
await verifyTxRequest({
requestToken,
wallet,
appDomain: origin,
});
set(requestTokenStore, requestToken);
} catch (error) {
console.error(error);
set(isUnauthorizedTransactionStore, true);
}
},
[location.search]
);
}

View File

@@ -1,53 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { transactionPayloadStore } from '@store/transaction';
import {
currentNetworkKeyStore,
currentNetworkStore,
Network,
networksStore,
} from '@store/networks';
export function useNetworkSwitchCallback() {
return useRecoilCallback(
({ snapshot, set }) =>
async () => {
const payload = await snapshot.getPromise(transactionPayloadStore);
if (!payload || !payload.network) return;
let foundNetwork = false;
const [currentNetwork, networks] = await Promise.all([
snapshot.getPromise(currentNetworkStore),
snapshot.getPromise(networksStore),
]);
// try to find an exact url match
if (payload.network.coreApiUrl !== currentNetwork.url) {
const newNetworkKey = Object.keys(networks).find(key => {
const network = networks[key] as Network;
return network.url === payload.network?.coreApiUrl;
});
if (newNetworkKey) {
console.debug('Changing to new network to match node URL', newNetworkKey);
set(currentNetworkKeyStore, newNetworkKey);
foundNetwork = true;
}
}
// try to find a network that matches chain id
if (!foundNetwork && payload.network.chainId !== currentNetwork.chainId) {
const newNetworkKey = Object.keys(networks).find(key => {
const network = networks[key] as Network;
return network.chainId === payload.network?.chainId;
});
if (newNetworkKey) {
console.debug('Changing to new network from chainID', newNetworkKey);
set(currentNetworkKeyStore, newNetworkKey);
return;
}
}
},
[]
);
}

View File

@@ -1,59 +0,0 @@
import { CallbackInterface, useRecoilCallback } from 'recoil';
import {
postConditionsHasSetStore,
postConditionsStore,
transactionPayloadStore,
} from '@store/transaction';
import { getPostCondition, handlePostConditions } from '@common/post-condition-utils';
import { currentAccountStxAddressStore } from '@store/accounts';
export function usePostConditionsCallback() {
return useRecoilCallback(postConditionsCallback, []);
}
function postConditionsCallback({ snapshot, set }: CallbackInterface) {
return async (useCurrentAddress?: boolean) => {
const [payload, existingPostConditions, hasSet, currentAddress] = await Promise.all([
snapshot.getPromise(transactionPayloadStore),
snapshot.getPromise(postConditionsStore),
snapshot.getPromise(postConditionsHasSetStore),
snapshot.getPromise(currentAccountStxAddressStore),
]);
if (!payload) return;
const { stxAddress, postConditions } = payload;
if (hasSet || !currentAddress) return;
if (!hasSet && postConditions && postConditions.length) {
if (stxAddress && useCurrentAddress) {
// we have yet to set the post conditions to the store
// let's ensure the principal(s) are set correctly
const newConditions = handlePostConditions(postConditions, stxAddress, currentAddress);
set(postConditionsStore, newConditions);
set(postConditionsHasSetStore, true);
return;
}
// there is no `stxAddress` set, but it might be
// the case that a post condition is of string type
// so let's map over them and deserialize them
const newConditions = postConditions.map(getPostCondition);
set(postConditionsStore, newConditions);
set(postConditionsHasSetStore, true);
return;
} else if (!hasSet && existingPostConditions.length) {
if (stxAddress && useCurrentAddress) {
// we have existing post conditions, lets ensure
// the principal(s) are set correctly
const newConditions = handlePostConditions(
existingPostConditions,
stxAddress,
currentAddress
);
set(postConditionsStore, newConditions);
set(postConditionsHasSetStore, true);
return;
}
}
};
}

View File

@@ -1,10 +1,10 @@
import { accountBalancesStore, accountDataStore } from '@store/accounts';
import { accountBalancesState, accountDataState } from '@store/accounts';
import { useLoadable } from './use-loadable';
export const useFetchAccountData = () => {
return useLoadable(accountDataStore);
return useLoadable(accountDataState);
};
export const useFetchBalances = () => {
return useLoadable(accountBalancesStore);
return useLoadable(accountBalancesState);
};

View File

@@ -1,9 +1,9 @@
import { useRecoilValue } from 'recoil';
import { currentAccountStore, currentAccountStxAddressStore } from '@store/accounts';
import { currentAccountState, currentAccountStxAddressState } from '@store/accounts';
export function useCurrentAccount() {
const accountInfo = useRecoilValue(currentAccountStore);
const stxAddress = useRecoilValue(currentAccountStxAddressStore);
const accountInfo = useRecoilValue(currentAccountState);
const stxAddress = useRecoilValue(currentAccountStxAddressState);
return {
...accountInfo,
stxAddress,

View File

@@ -1,12 +1,12 @@
import { useRecoilValue } from 'recoil';
import { currentNetworkStore } from '@store/networks';
import { currentNetworkState } from '@store/networks';
import { useMemo } from 'react';
import { ChainID } from '@stacks/transactions';
type Modes = 'testnet' | 'mainnet';
export function useCurrentNetwork() {
const network = useRecoilValue(currentNetworkStore);
const network = useRecoilValue(currentNetworkState);
const isTestnet = useMemo(() => network.chainId === ChainID.Testnet, [network.chainId]);
const mode = (isTestnet ? 'testnet' : 'mainnet') as Modes;
return {

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useSetRecoilState } from 'recoil';
import { currentPostConditionIndexStore, postConditionsStore } from '@store/transaction';
import { selectedAssetIdState } from '@store/assets/asset-search';

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useRecoilValueLoadable, RecoilValue } from 'recoil';
import { useCurrentNetwork } from '@common/hooks/use-current-network';
/**
* Wrap a Recoil loadable and add a `value` property, which
@@ -8,7 +7,6 @@ import { useCurrentNetwork } from '@common/hooks/use-current-network';
*/
export const useLoadable = <T>(recoilValue: RecoilValue<T>) => {
const loadable = useRecoilValueLoadable(recoilValue);
const network = useCurrentNetwork();
const [value, setValue] = useState<T | undefined>(loadable.valueMaybe() || undefined);
useEffect(() => {
@@ -17,11 +15,6 @@ export const useLoadable = <T>(recoilValue: RecoilValue<T>) => {
}
}, [loadable, value]);
useEffect(() => {
// we want to revalidate on network change
setValue(undefined);
}, [network.url]);
return {
...loadable,
key: recoilValue.key,

View File

@@ -1,6 +1,10 @@
import { useRecoilState } from 'recoil';
import { loadingState } from '@store/ui';
export enum LOADING_KEYS {
SUBMIT_TRANSACTION = 'loading/SUBMIT_TRANSACTION',
}
export function useLoading(key: string) {
const [state, setState] = useRecoilState(loadingState(key));

View File

@@ -1,6 +1,6 @@
import { useRecoilCallback, waitForAll } from 'recoil';
import { stacksNetworkStore } from '@store/networks';
import { currentAccountStore } from '@store/accounts';
import { currentAccountState } from '@store/accounts';
import { correctNonceState } from '@store/accounts/nonce';
import { makeSTXTokenTransfer, StacksTransaction } from '@stacks/transactions';
import BN from 'bn.js';
@@ -22,10 +22,11 @@ export function useMakeStxTransfer() {
const { network, account, nonce } = await snapshot.getPromise(
waitForAll({
network: stacksNetworkStore,
account: currentAccountStore,
account: currentAccountState,
nonce: correctNonceState,
})
);
if (!account) return;
return makeSTXTokenTransfer({

View File

@@ -1,13 +1,8 @@
import { useRecoilValue } from 'recoil';
import { requestTokenStore } from '@store/transaction';
import { getRequestOrigin, StorageKey } from '../../storage';
import { requestTokenState } from '@store/transactions/requests';
export function useOrigin() {
const requestToken = useRecoilValue(requestTokenStore);
if (!requestToken) return null;
try {
return getRequestOrigin(StorageKey.transactionRequests, requestToken);
} catch (e) {
return null;
}
const requestToken = useRecoilValue(requestTokenState);
return requestToken ? getRequestOrigin(StorageKey.transactionRequests, requestToken) : null;
}

View File

@@ -1,6 +1,6 @@
import { useRecoilValue } from 'recoil';
import { pendingTransactionStore } from '@store/transaction';
import { pendingTransactionState } from '@store/transactions';
export function usePendingTransaction() {
return useRecoilValue(pendingTransactionStore);
return useRecoilValue(pendingTransactionState);
}

View File

@@ -0,0 +1,6 @@
import { useLoadable } from '@common/hooks/use-loadable';
import { postConditionsState } from '@store/transactions';
export function usePostconditions() {
return useLoadable(postConditionsState);
}

View File

@@ -1,5 +1,5 @@
import { useRecoilCallback } from 'recoil';
import { apiRevalidation } from '@store/common/api';
import { apiRevalidation } from '@store/common/api-helpers';
export function useRevalidateApi() {
return useRecoilCallback(({ snapshot, set }) => async () => {

View File

@@ -2,17 +2,12 @@ import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { decodeToken } from 'jsontokens';
import { DecodedAuthRequest } from '@common/dev/types';
import { useWallet } from '@common/hooks/use-wallet';
import { authRequestState, currentScreenState } from '@store/onboarding';
import { getRequestOrigin, StorageKey } from '../../../storage';
import { getRequestOrigin, StorageKey } from 'storage';
import { ScreenPaths } from '@store/common/types';
import { useOnboardingState } from '../use-onboarding-state';
import { useOnboardingState } from './use-onboarding-state';
import { authRequestState, currentScreenState } from '@store/onboarding';
export function useSaveAuthRequest() {
const { wallet } = useWallet();

View File

@@ -1,78 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { usePrevious } from '@stacks/ui';
import { requestTokenStore, transactionPayloadStore } from '@store/transaction';
import { currentAccountStxAddressStore } from '@store/accounts';
import { hasRehydratedVaultStore } from '@store/wallet';
import { useAccountSwitchCallback } from '@common/hooks/callbacks/use-account-switch-callback';
import { usePostConditionsCallback } from '@common/hooks/callbacks/use-post-conditions-callback';
import { useNetworkSwitchCallback } from '@common/hooks/callbacks/use-network-switch-callback';
import { useDecodeRequestCallback } from '@common/hooks/callbacks/use-decode-request-callback';
export const useSetupTx = () => {
const hasRehydratedVault = useRecoilValue(hasRehydratedVaultStore);
const [hasMounted, setHasMounted] = useState(false);
const currentAccountStxAddress = useRecoilValue(currentAccountStxAddressStore);
const previousAccountStxAddress = usePrevious(currentAccountStxAddress);
const requestToken = useRecoilValue(requestTokenStore);
const payload = useRecoilValue(transactionPayloadStore);
const handleDecodeRequest = useDecodeRequestCallback();
const handleNetworkSwitch = useNetworkSwitchCallback();
const handleAccountSwitch = useAccountSwitchCallback();
const handlePostConditions = usePostConditionsCallback();
const handleInit = useCallback(async () => {
if (!hasRehydratedVault) return;
if (!requestToken) {
await handleDecodeRequest();
}
if (payload) {
await Promise.all([handleNetworkSwitch(), handleAccountSwitch()]);
if (requestToken && currentAccountStxAddress) {
if (
!hasMounted ||
!previousAccountStxAddress ||
previousAccountStxAddress !== currentAccountStxAddress
) {
if (!hasMounted) {
setHasMounted(true);
}
await handlePostConditions(true);
}
}
}
}, [
hasRehydratedVault,
hasMounted,
payload,
requestToken,
previousAccountStxAddress,
setHasMounted,
currentAccountStxAddress,
handlePostConditions,
handleDecodeRequest,
handleNetworkSwitch,
handleAccountSwitch,
]);
useEffect(() => {
void handleInit();
}, [
payload,
handleInit,
hasMounted,
previousAccountStxAddress,
currentAccountStxAddress,
handlePostConditions,
requestToken,
handleDecodeRequest,
handleNetworkSwitch,
handleAccountSwitch,
]);
return !!(requestToken && payload && hasMounted);
};

View File

@@ -0,0 +1,33 @@
import { useWallet } from '@common/hooks/use-wallet';
import { useRecoilState, useRecoilValue } from 'recoil';
import { hasSwitchedAccountsState, transactionAccountIndexState } from '@store/accounts';
import { networkTransactionVersionState } from '@store/networks';
import { useCallback } from 'react';
const TIMEOUT = 350;
export const useSwitchAccount = (callback?: () => void) => {
const { wallet, currentAccountIndex, doSwitchAccount } = useWallet();
const txIndex = useRecoilValue(transactionAccountIndexState);
const transactionVersion = useRecoilValue(networkTransactionVersionState);
const [hasSwitched, setHasSwitched] = useRecoilState(hasSwitchedAccountsState);
const handleSwitchAccount = useCallback(
async index => {
if (typeof txIndex === 'number') setHasSwitched(true);
await doSwitchAccount(index);
if (callback) {
window.setTimeout(() => {
callback();
}, TIMEOUT);
}
},
[txIndex, setHasSwitched, doSwitchAccount, callback]
);
const accounts = wallet?.accounts || [];
const getIsActive = (index: number) =>
typeof txIndex === 'number' && !hasSwitched ? index === txIndex : index === currentAccountIndex;
return { accounts, handleSwitchAccount, getIsActive, transactionVersion };
};

View File

@@ -0,0 +1,59 @@
import {
useTransactionContractInterface,
useTransactionRequest,
} from '@common/hooks/use-transaction';
import { useRecoilValue } from 'recoil';
import { isUnauthorizedTransactionState, transactionBroadcastErrorState } from '@store/transaction';
import { useWallet } from '@common/hooks/use-wallet';
import { useFetchBalances } from '@common/hooks/use-account-info';
import { useMemo } from 'react';
import { TransactionErrorReason } from '@pages/transaction/transaction-error';
import BigNumber from 'bignumber.js';
import { TransactionTypes } from '@stacks/connect';
import { useTransactionFee } from '@common/hooks/use-transaction-fee';
export function useTransactionError() {
const transactionRequest = useTransactionRequest();
const fee = useTransactionFee();
const contractInterface = useTransactionContractInterface();
const broadcastError = useRecoilValue(transactionBroadcastErrorState);
const isUnauthorizedTransaction = useRecoilValue(isUnauthorizedTransactionState);
const { currentAccount } = useWallet();
const balances = useFetchBalances();
return useMemo<TransactionErrorReason | void>(() => {
if (isUnauthorizedTransaction) return TransactionErrorReason.Unauthorized;
if (!transactionRequest || balances.errorMaybe() || !currentAccount) {
return TransactionErrorReason.Generic;
}
if (
transactionRequest.txType === TransactionTypes.ContractCall &&
!contractInterface.isLoading &&
!contractInterface.contents
)
return TransactionErrorReason.NoContract;
if (broadcastError) return TransactionErrorReason.BroadcastError;
if (balances.value) {
const stxBalance = new BigNumber(balances.value.stx.balance);
if (transactionRequest.txType === TransactionTypes.STXTransfer) {
const transferAmount = new BigNumber(transactionRequest.amount);
if (transferAmount.gte(stxBalance))
return TransactionErrorReason.StxTransferInsufficientFunds;
}
if (fee && !fee.isSponsored && fee.amount) {
const feeAmount = new BigNumber(fee.amount);
if (feeAmount.gte(stxBalance)) return TransactionErrorReason.FeeInsufficientFunds;
}
}
return;
}, [
fee,
broadcastError,
contractInterface,
balances,
currentAccount,
transactionRequest,
isUnauthorizedTransaction,
]);
}

View File

@@ -0,0 +1,13 @@
import { useSignedTransaction } from '@common/hooks/use-transaction';
import { AuthType } from '@stacks/transactions';
export function useTransactionFee() {
const signedTransaction = useSignedTransaction();
if (!signedTransaction.value) return;
const isSponsored = signedTransaction.value.auth.authType === AuthType.Sponsored;
const amount = signedTransaction.value.auth.spendingCondition?.fee?.toNumber();
return {
amount,
isSponsored,
};
}

View File

@@ -0,0 +1,16 @@
import { useTransactionRequest } from '@common/hooks/use-transaction';
import { useMemo } from 'react';
import { TransactionTypes } from '@stacks/connect';
export function useTransactionPageTitle() {
const transactionRequest = useTransactionRequest();
const txType = transactionRequest?.txType;
return useMemo(() => {
if (!transactionRequest) return;
if (txType === TransactionTypes.STXTransfer) return 'Confirm transfer';
if (txType === TransactionTypes.ContractDeploy) return 'Deploy contract';
if (txType === TransactionTypes.ContractCall && 'functionName' in transactionRequest)
return transactionRequest.functionName || 'Sign transaction';
return 'Sign transaction';
}, [transactionRequest, txType]);
}

View File

@@ -0,0 +1,35 @@
import { useLoadable } from '@common/hooks/use-loadable';
import { requestTokenPayloadState } from '@store/transactions/requests';
import { postConditionsState, signedTransactionState } from '@store/transactions';
import {
transactionContractInterfaceState,
transactionContractSourceState,
transactionFunctionsState,
} from '@store/transactions/contract-call';
export function useTransactionRequest() {
const payload = useLoadable(requestTokenPayloadState);
return payload?.value;
}
export function useTransactionContractInterface() {
return useLoadable(transactionContractInterfaceState);
}
export function useTransactionContractSource() {
return useLoadable(transactionContractSourceState);
}
export function useTransactionFunction() {
const payload = useLoadable(transactionFunctionsState);
return payload?.value;
}
export function useTransactionPostConditions() {
const payload = useLoadable(postConditionsState);
return payload?.value;
}
export function useSignedTransaction() {
return useLoadable(signedTransactionState);
}

View File

@@ -1,56 +1,63 @@
import { useWallet } from './use-wallet';
import {
contractSourceStore,
contractInterfaceStore,
pendingTransactionStore,
signedTransactionStore,
pendingTransactionFunctionSelector,
transactionBroadcastErrorStore,
requestTokenStore,
isUnauthorizedTransactionStore,
} from '@store/transaction';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { currentNetworkStore } from '@store/networks';
import { transactionBroadcastErrorState, isUnauthorizedTransactionState } from '@store/transaction';
import { useRecoilCallback, useRecoilValue, waitForAll } from 'recoil';
import { currentNetworkState } from '@store/networks';
import { finishTransaction } from '@common/transaction-utils';
import { useLoadable } from '@common/hooks/use-loadable';
import { finalizeTxSignature } from '@common/utils';
import {
useSignedTransaction,
useTransactionContractInterface,
useTransactionContractSource,
useTransactionFunction,
useTransactionRequest,
} from '@common/hooks/use-transaction';
import { signedTransactionState } from '@store/transactions';
import { requestTokenPayloadState, requestTokenState } from '@store/transactions/requests';
export const useTxState = () => {
export function useHandleSubmitPendingTransaction() {
const { doSetLatestNonce } = useWallet();
const broadcastError = useRecoilValue(transactionBroadcastErrorStore);
const pendingTransaction = useRecoilValue(pendingTransactionStore);
const contractSource = useLoadable(contractSourceStore);
const contractInterface = useLoadable(contractInterfaceStore);
const pendingTransactionFunction = useLoadable(pendingTransactionFunctionSelector);
const signedTransaction = useLoadable(signedTransactionStore);
const isUnauthorizedTransaction = useRecoilValue(isUnauthorizedTransactionStore);
const doSubmitPendingTransaction = useRecoilCallback(
return useRecoilCallback(
({ snapshot, set }) =>
async () => {
const pendingTransaction = await snapshot.getPromise(pendingTransactionStore);
const requestPayload = await snapshot.getPromise(requestTokenStore);
if (!pendingTransaction || !requestPayload) {
set(transactionBroadcastErrorStore, 'No pending transaction found.');
const { tx, pendingTransaction, requestToken, network } = await snapshot.getPromise(
waitForAll({
tx: signedTransactionState,
pendingTransaction: requestTokenPayloadState,
requestToken: requestTokenState,
network: currentNetworkState,
})
);
if (!pendingTransaction || !requestToken || !tx) {
set(transactionBroadcastErrorState, 'No pending transaction found.');
return;
}
const tx = await snapshot.getPromise(signedTransactionStore);
const currentNetwork = await snapshot.getPromise(currentNetworkStore);
if (!tx) return;
try {
const result = await finishTransaction({
tx,
pendingTransaction,
nodeUrl: currentNetwork.url,
nodeUrl: network.url,
});
await doSetLatestNonce(tx);
finalizeTxSignature(requestPayload, result);
finalizeTxSignature(requestToken, result);
} catch (error) {
set(transactionBroadcastErrorStore, error.message);
set(transactionBroadcastErrorState, error.message);
}
},
[doSetLatestNonce]
);
}
export const useTxState = () => {
const pendingTransaction = useTransactionRequest();
const contractInterface = useTransactionContractInterface();
const pendingTransactionFunction = useTransactionFunction();
const signedTransaction = useSignedTransaction();
const contractSource = useTransactionContractSource();
const handleSubmitPendingTransaction = useHandleSubmitPendingTransaction();
const broadcastError = useRecoilValue(transactionBroadcastErrorState);
const isUnauthorizedTransaction = useRecoilValue(isUnauthorizedTransactionState);
return {
pendingTransaction,
@@ -58,7 +65,7 @@ export const useTxState = () => {
contractSource,
contractInterface,
pendingTransactionFunction,
doSubmitPendingTransaction,
handleSubmitPendingTransaction,
broadcastError,
isUnauthorizedTransaction,
};

View File

@@ -16,6 +16,7 @@ import {
import { InMemoryVault } from '@background/vault';
import { InternalMethods } from '@content-scripts/message-types';
import { currentAccountIndexStore } from '@store/accounts';
import { textToBytes } from '@store/common/utils';
type Set = <T>(store: RecoilState<T>, value: T) => void;
@@ -27,7 +28,7 @@ const innerMessageWrapper = async (message: VaultActions, set: Set) => {
set(hasRehydratedVaultStore, true);
set(hasSetPasswordState, vault.hasSetPassword);
set(walletState, vault.wallet);
set(secretKeyState, vault.secretKey);
set(secretKeyState, vault.secretKey ? textToBytes(vault.secretKey) : undefined);
set(currentAccountIndexStore, vault.currentAccountIndex);
set(encryptedSecretKeyStore, vault.encryptedSecretKey);
resolve(vault);

View File

@@ -9,11 +9,11 @@ import {
import { useRecoilValue, useRecoilState, useRecoilCallback } from 'recoil';
import { gaiaUrl } from '@common/constants';
import {
currentNetworkKeyStore,
currentNetworkStore,
networksStore,
currentTransactionVersion,
latestBlockHeightStore,
currentNetworkKeyState,
currentNetworkState,
networksState,
networkTransactionVersionState,
latestBlockHeightState,
} from '@store/networks';
import {
walletState,
@@ -28,14 +28,15 @@ import { useVaultMessenger } from '@common/hooks/use-vault-messenger';
import { useOnboardingState } from './use-onboarding-state';
import { finalizeAuthResponse } from '@common/utils';
import { apiRevalidation } from '@store/common/api';
import { apiRevalidation } from '@store/common/api-helpers';
import { useLoadable } from '@common/hooks/use-loadable';
import {
currentAccountIndexStore,
currentAccountStore,
currentAccountStxAddressStore,
currentAccountState,
currentAccountStxAddressState,
} from '@store/accounts';
import { latestNoncesState } from '@store/accounts/nonce';
import { bytesToText } from '@store/common/utils';
export const useWallet = () => {
const hasRehydratedVault = useRecoilValue(hasRehydratedVaultStore);
@@ -44,12 +45,12 @@ export const useWallet = () => {
const encryptedSecretKey = useRecoilValue(encryptedSecretKeyStore);
const currentAccountIndex = useRecoilValue(currentAccountIndexStore);
const hasSetPassword = useRecoilValue(hasSetPasswordState);
const currentAccount = useRecoilValue(currentAccountStore);
const currentAccountStxAddress = useRecoilValue(currentAccountStxAddressStore);
const transactionVersion = useRecoilValue(currentTransactionVersion);
const networks = useRecoilValue(networksStore);
const currentNetwork = useRecoilValue(currentNetworkStore);
const currentNetworkKey = useRecoilValue(currentNetworkKeyStore);
const currentAccount = useRecoilValue(currentAccountState);
const currentAccountStxAddress = useRecoilValue(currentAccountStxAddressState);
const transactionVersion = useRecoilValue(networkTransactionVersionState);
const networks = useRecoilValue(networksState);
const currentNetwork = useRecoilValue(currentNetworkState);
const currentNetworkKey = useRecoilValue(currentNetworkKeyState);
const walletConfig = useLoadable(walletConfigStore);
const vaultMessenger = useVaultMessenger();
@@ -67,9 +68,9 @@ export const useWallet = () => {
const newNonce = tx.auth.spendingCondition?.nonce.toNumber();
if (newNonce !== undefined) {
set(apiRevalidation, current => (current as number) + 1);
const blockHeight = await snapshot.getPromise(latestBlockHeightStore);
const network = await snapshot.getPromise(currentNetworkStore);
const address = await snapshot.getPromise(currentAccountStxAddressStore);
const blockHeight = await snapshot.getPromise(latestBlockHeightState);
const network = await snapshot.getPromise(currentNetworkState);
const address = await snapshot.getPromise(currentAccountStxAddressState);
set(latestNoncesState([network.url, address || '']), () => ({
blockHeight,
nonce: newNonce,
@@ -132,7 +133,7 @@ export const useWallet = () => {
return {
hasRehydratedVault,
wallet,
secretKey,
secretKey: secretKey ? bytesToText(secretKey) : undefined,
isSignedIn,
currentAccount,
currentAccountIndex,

View File

@@ -17,7 +17,7 @@ import {
import BigNumber from 'bignumber.js';
import { c32addressDecode } from 'c32check';
import { getAssetStringParts } from '@stacks/ui-utils';
import { Network } from '@store/networks';
import { Network } from './constants';
import { STX_DECIMALS } from './constants';
import { abbreviateNumber } from '@common/utils';
@@ -47,7 +47,7 @@ export const encodeContractCallArgument = ({ type, value }: ContractCallArgument
export const stacksValue = ({
value,
fixedDecimals = false,
fixedDecimals = true,
withTicker = true,
abbreviate = false,
}: {

View File

@@ -126,12 +126,12 @@ export const generateTransaction = async ({
txData: TransactionPayload;
}) => {
let tx: StacksTransaction | null = null;
console.log('generate', txData, nonce);
if (!txData.network?.getTransferFeeEstimateApiUrl) {
const network =
txData.network =
txData.network?.version === TransactionVersion.Mainnet
? new StacksMainnet()
: new StacksTestnet();
txData.network = network;
}
switch (txData.txType) {
case TransactionTypes.ContractCall:
@@ -152,24 +152,25 @@ export const generateTransaction = async ({
return tx;
};
interface FinishTransactionOptions {
tx: StacksTransaction;
pendingTransaction: TransactionPayloadWithAttachment;
nodeUrl: string;
}
export const finishTransaction = async ({
tx,
pendingTransaction,
nodeUrl,
}: {
tx: StacksTransaction;
pendingTransaction: TransactionPayloadWithAttachment;
nodeUrl: string;
}): Promise<TxResult> => {
}: FinishTransactionOptions): Promise<TxResult> => {
const serialized = tx.serialize();
const txRaw = `0x${serialized.toString('hex')}`;
// if sponsored, return raw tx
if (tx.auth.authType === AuthType.Sponsored) {
if (tx.auth.authType === AuthType.Sponsored)
return {
txRaw,
};
}
const response = await broadcastRawTransaction(
serialized,

View File

@@ -4,7 +4,6 @@ import { wordlists } from 'bip39';
import { isValidUrl } from './validate-url';
import { getTab, deleteTabForRequest, StorageKey } from '../storage';
import { BufferReader, deserializePostCondition, PostCondition } from '@stacks/transactions';
import { KEBAB_REGEX } from '@common/constants';
import {
AuthenticationResponseMessage,
ExternalMethods,
@@ -13,6 +12,9 @@ import {
TxResult,
} from '@content-scripts/message-types';
import { KEBAB_REGEX, Network } from '@common/constants';
import { StacksNetwork } from '@stacks/network';
function kebabCase(str: string) {
return str.replace(KEBAB_REGEX, match => '-' + match.toLowerCase());
}
@@ -265,3 +267,17 @@ export function hexToHumanReadable(hex: string) {
if (isUtf8(buff)) return buff.toString('utf8');
return `0x${hex}`;
}
export function findMatchingNetworkKey(
txNetwork: StacksNetwork,
networks: Record<string, Network>
) {
if (!networks) return;
const newNetworkKey = Object.keys(networks).find((key: string) => {
const network = networks[key] as Network;
return network.url === txNetwork?.coreApiUrl || network.chainId === txNetwork?.chainId;
});
if (newNetworkKey) return newNetworkKey;
return null;
}

View File

@@ -5,5 +5,9 @@ const hiroHeaders: HeadersInit = {
export function fetcher(input: RequestInfo, init: RequestInit = {}) {
const initHeaders = init.headers || {};
return fetch(input, { ...init, headers: { ...initHeaders, ...hiroHeaders } });
return fetch(input, {
credentials: 'omit',
...init,
headers: { ...initHeaders, ...hiroHeaders },
});
}

View File

@@ -0,0 +1,24 @@
import React, { memo } from 'react';
import { Stack } from '@stacks/ui';
import { AccountAvatar } from '@components/account-avatar';
import { Caption, Title } from '@components/typography';
import { truncateMiddle } from '@stacks/ui-utils';
import { getAccountDisplayName } from '@stacks/wallet-sdk';
import { useAccountNames } from '@common/hooks/use-account-names';
import { AccountWithAddress } from '@store/accounts';
export const AccountItem = memo(({ account, ...rest }: { account: AccountWithAddress }) => {
const names = useAccountNames();
const name = names.value?.[account.index]?.names?.[0] || getAccountDisplayName(account);
return (
<Stack isInline alignItems="center" spacing="base" {...rest}>
<AccountAvatar name={name} account={account} />
<Stack spacing="base-tight">
<Title fontSize={2} lineHeight="1rem" fontWeight="400">
{name}
</Title>
<Caption>{truncateMiddle(account.address, 9)}</Caption>
</Stack>
</Stack>
);
});

View File

@@ -1,19 +1,20 @@
import React, { useState, useEffect } from 'react';
import { Flex, FlexProps, Spinner, color, Stack } from '@stacks/ui';
import { Caption, Text, Title } from '@components/typography';
import { color, Flex, FlexProps, Spinner, Stack } from '@stacks/ui';
import { Caption, Title, Text } from '@components/typography';
import { ScreenPaths } from '@store/common/types';
import { PlusInCircle } from '@components/icons/plus-in-circle';
import { ListItem } from './list-item';
import { useDoChangeScreen } from '@common/hooks/use-do-change-screen';
import { useWallet } from '@common/hooks/use-wallet';
import { getStxAddress, Account, getAccountDisplayName } from '@stacks/wallet-sdk';
import { useOnboardingState } from '@common/hooks/use-onboarding-state';
import { truncateMiddle } from '@stacks/ui-utils';
import { useAccountDisplayName, useAccountNames } from '@common/hooks/use-account-names';
import { accountsWithAddressState } from '@store/accounts';
import { useLoadable } from '@common/hooks/use-loadable';
import { Account, getAccountDisplayName } from '@stacks/wallet-sdk';
import { AccountAvatar } from '@components/account-avatar';
import { SpaceBetween } from '@components/space-between';
import { useAccountDisplayName, useAccountNames } from '@common/hooks/use-account-names';
import { ScreenPaths } from '@store/common/types';
import { PlusInCircle } from '@components/icons/plus-in-circle';
const loadingProps = { color: '#A1A7B3' };
const getLoadingProps = (loading: boolean) => (loading ? loadingProps : {});
@@ -28,7 +29,7 @@ interface AccountItemProps extends FlexProps {
account: Account;
}
const AccountItem: React.FC<AccountItemProps> = ({
export const AccountItem: React.FC<AccountItemProps> = ({
address,
selectedAddress,
account,
@@ -36,7 +37,6 @@ const AccountItem: React.FC<AccountItemProps> = ({
}) => {
const { decodedAuthRequest } = useOnboardingState();
const name = useAccountDisplayName(account);
const loading = address === selectedAddress;
const showLoadingProps = !!selectedAddress || !decodedAuthRequest;
return (
@@ -73,7 +73,8 @@ export const Accounts: React.FC<AccountsProps> = ({
next,
...rest
}) => {
const { wallet, transactionVersion } = useWallet();
const { wallet } = useWallet();
const { value: accounts } = useLoadable(accountsWithAddressState);
const [selectedAddress, setSelectedAddress] = useState<null | string>(null);
const { decodedAuthRequest } = useOnboardingState();
const doChangeScreen = useDoChangeScreen();
@@ -83,15 +84,7 @@ export const Accounts: React.FC<AccountsProps> = ({
}
}, [accountIndex, setSelectedAddress, selectedAddress]);
const names = useAccountNames();
if (!wallet) return null;
const accounts = wallet.accounts.map(account => ({
...account,
stxAddress: getStxAddress({ account, transactionVersion }),
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
number: account.index + 1,
}));
if (!wallet || !accounts) return null;
const disableSelect = !decodedAuthRequest || !!selectedAddress;
return (
@@ -101,21 +94,21 @@ export const Accounts: React.FC<AccountsProps> = ({
return (
<ListItem
key={account.stxAddress}
key={account.address}
isFirst={index === 0}
cursor={disableSelect ? 'not-allowed' : 'pointer'}
iconComponent={() => <AccountAvatar account={account} name={name} />}
iconComponent={() => <AccountAvatar account={account} name={name} mr={3} />}
hasAction={!!next && selectedAddress === null}
data-test={`account-${(account?.username || account?.stxAddress).split('.')[0]}`}
data-test={`account-${(account.username || account.address).split('.')[0]}`}
onClick={() => {
if (!next) return;
if (selectedAddress) return;
setSelectedAddress(account?.stxAddress);
setSelectedAddress(account.address);
next(index);
}}
>
<AccountItem
address={account.stxAddress}
address={account.address}
selectedAddress={selectedAddress}
data-test={`account-index-${index}`}
account={account}

View File

@@ -11,6 +11,7 @@ import { VaultLoader } from '@components/vault-loader';
import { AccountsDrawer } from './drawer/accounts';
import { NetworksDrawer } from './drawer/networks-drawer';
import { Toaster } from 'react-hot-toast';
import { DebugObserver } from '@components/debug-observer';
export const App: React.FC = () => {
useEffect(() => {
@@ -19,6 +20,7 @@ export const App: React.FC = () => {
return (
<ThemeProvider theme={theme}>
<RecoilRoot>
<DebugObserver />
<ColorModeProvider defaultMode="light">
<>
<GlobalStyles />

View File

@@ -0,0 +1,17 @@
import { useRecoilSnapshot } from 'recoil';
import { useEffect } from 'react';
export function DebugObserver() {
const snapshot = useRecoilSnapshot();
useEffect(() => {
if (process.env.NODE_ENV === 'development' && !!localStorage.getItem('DEBUG')) {
for (const node of snapshot.getNodes_UNSTABLE({ isModified: true })) {
console.group(`[state change] ${node.key}`);
console.log(snapshot.getLoadable(node).contents);
console.groupEnd();
}
}
}, [snapshot]);
return null;
}

View File

@@ -1,42 +1,20 @@
import React, { memo, useCallback } from 'react';
import React, { memo } from 'react';
import { Box, Fade, Button, Stack, color } from '@stacks/ui';
import { Title, Caption } from '@components/typography';
import { useWallet } from '@common/hooks/use-wallet';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { accountDrawerStep, AccountStep } from '@store/ui';
import { getAccountDisplayName, getStxAddress } from '@stacks/wallet-sdk';
import { currentTransactionVersion } from '@store/networks';
import { truncateMiddle } from '@stacks/ui-utils';
import { SpaceBetween } from '@components/space-between';
import { IconCheck } from '@tabler/icons';
import { AccountAvatar } from '@components/account-avatar';
import { useAccountNames } from '@common/hooks/use-account-names';
import { useSwitchAccount } from '@common/hooks/use-switch-account';
interface SwitchAccountProps {
close: () => void;
}
const TIMEOUT = 350;
const useSwitchAccount = (handleClose: () => void) => {
const { wallet, currentAccountIndex, doSwitchAccount } = useWallet();
const transactionVersion = useRecoilValue(currentTransactionVersion);
const handleSwitchAccount = useCallback(
async index => {
await doSwitchAccount(index);
window.setTimeout(() => {
handleClose();
}, TIMEOUT);
},
[doSwitchAccount, handleClose]
);
const accounts = wallet?.accounts || [];
const getIsActive = (index: number) => index === currentAccountIndex;
return { accounts, handleSwitchAccount, getIsActive, transactionVersion };
};
// eslint-disable-next-line no-warning-comments
// TODO: this page is nearly identical to the network switcher abstract it out into a shared component
const AccountList: React.FC<{ handleClose: () => void }> = memo(({ handleClose }) => {

View File

@@ -6,7 +6,7 @@ import { useWallet } from '@common/hooks/use-wallet';
import { CheckmarkIcon } from '@components/icons/checkmark-icon';
import { useDoChangeScreen } from '@common/hooks/use-do-change-screen';
import { ScreenPaths } from '@store/common/types';
import { currentNetworkKeyStore } from '@store/networks';
import { currentNetworkKeyState } from '@store/networks';
import { showNetworksStore } from '@store/ui';
import { useDrawers } from '@common/hooks/use-drawers';
import { Caption, Title } from '@components/typography';
@@ -14,7 +14,7 @@ import { Caption, Title } from '@components/typography';
const NetworkListItem: React.FC<{ item: string } & BoxProps> = memo(({ item, ...props }) => {
const { setShowNetworks } = useDrawers();
const { networks, currentNetworkKey } = useWallet();
const setCurrentNetworkKey = useSetRecoilState(currentNetworkKeyStore);
const setCurrentNetworkKey = useSetRecoilState(currentNetworkKeyState);
const network = networks[item];
const delayToShowCheckmarkMotion = 350;

View File

@@ -2,17 +2,12 @@ import React from 'react';
import { PopupContainer } from '@components/popup/container';
import { Box, Text, Button } from '@stacks/ui';
import { useLoadable } from '@common/hooks/use-loadable';
import {
contractSourceStore,
contractInterfaceStore,
pendingTransactionFunctionSelector,
signedTransactionStore,
pendingTransactionStore,
} from '@store/transaction';
import { accountDataStore } from '@store/accounts';
import { accountDataState } from '@store/accounts';
import { walletState } from '@store/wallet';
import { useRecoilValue } from 'recoil';
import { Header } from '@components/header';
import { useTransactionRequest } from '@common/hooks/use-transaction';
import { signedTransactionState } from '@store/transactions';
const openGithubIssue = (loadable: ReturnType<typeof useLoadable>) => {
const issueParams = new URLSearchParams();
@@ -58,16 +53,11 @@ type Loadables = ReturnType<typeof useLoadable>[];
*
*/
export const ErrorBoundary: React.FC = ({ children }) => {
const pendingTransaction = useRecoilValue(pendingTransactionStore);
const pendingTransaction = useTransactionRequest();
const wallet = useRecoilValue(walletState);
let loadables: Loadables = [];
const walletLoadables: Loadables = [useLoadable(accountDataStore)];
const txLoadables: Loadables = [
useLoadable(contractSourceStore),
useLoadable(contractInterfaceStore),
useLoadable(pendingTransactionFunctionSelector),
useLoadable(signedTransactionStore),
];
const walletLoadables: Loadables = [useLoadable(accountDataState)];
const txLoadables: Loadables = [useLoadable(signedTransactionState)];
if (wallet) {
loadables = loadables.concat(walletLoadables);
@@ -81,6 +71,7 @@ export const ErrorBoundary: React.FC = ({ children }) => {
if (errorLoadables.length > 0) {
const [loadable] = errorLoadables;
console.log(errorLoadables);
const error = errorLoadables[0].errorOrThrow();
return (
<PopupContainer header={<Header title="Uh oh! Something went wrong." />}>

View File

@@ -1,13 +1,13 @@
import React, { memo, useMemo } from 'react';
import { Box, color, Flex, FlexProps, Text } from '@stacks/ui';
import { useRecoilValue } from 'recoil';
import { currentNetworkStore } from '@store/networks';
import { currentNetworkState } from '@store/networks';
import { ChainID } from '@stacks/transactions';
import { IconFlask } from '@tabler/icons';
import { useDrawers } from '@common/hooks/use-drawers';
export const NetworkModeBadge: React.FC<FlexProps> = memo(props => {
const { chainId } = useRecoilValue(currentNetworkStore);
const { chainId } = useRecoilValue(currentNetworkState);
const isTestnet = useMemo(() => chainId === ChainID.Testnet, [chainId]);
const { setShowNetworks } = useDrawers();

View File

@@ -19,7 +19,7 @@ import { ScreenPaths } from '@store/common/types';
import { useDoChangeScreen } from '@common/hooks/use-do-change-screen';
import { useWallet } from '@common/hooks/use-wallet';
import { useOnboardingState } from '@common/hooks/use-onboarding-state';
import { useSaveAuthRequest } from '@common/hooks/callbacks/use-save-auth-request-callback';
import { useSaveAuthRequest } from '@common/hooks/use-save-auth-request-callback';
import { Route as RouterRoute, Routes as RoutesDom, useLocation } from 'react-router-dom';
import { Navigate } from '@components/navigate';
import { AccountGate } from '@components/account-gate';

View File

@@ -0,0 +1,85 @@
import React, { memo, useCallback } from 'react';
import { Box, Button, color, Stack, StackProps } from '@stacks/ui';
import { LOADING_KEYS, useLoading } from '@common/hooks/use-loading';
import { useDrawers } from '@common/hooks/use-drawers';
import { SpaceBetween } from '@components/space-between';
import { Caption } from '@components/typography';
import { NetworkRowItem } from '@components/network-row-item';
import { useTransactionError } from '@common/hooks/use-transaction-error';
import { FeeComponent } from '@components/transactions/fee';
import { useSignedTransaction } from '@common/hooks/use-transaction';
import { useHandleSubmitPendingTransaction } from '@common/hooks/use-tx-state';
import { TransactionErrorReason } from '@pages/transaction/transaction-error';
import { FiAlertTriangle } from 'react-icons/fi';
const MinimalErrorMessage = memo((props: StackProps) => {
const error = useTransactionError();
if (!error) return null;
const getTitle = () => {
if (error) {
switch (error) {
case TransactionErrorReason.Unauthorized:
return 'Unauthorized request';
case TransactionErrorReason.NoContract:
return 'Contract not found';
case TransactionErrorReason.StxTransferInsufficientFunds:
case TransactionErrorReason.FeeInsufficientFunds:
return 'Insufficient balance';
case TransactionErrorReason.BroadcastError:
return 'Broadcast error';
case TransactionErrorReason.Generic:
return 'Something went wrong';
}
}
return null;
};
return (
<Stack alignItems="center" bg="#FCEEED" p="base" borderRadius="12px" isInline {...props}>
<Box color={color('feedback-error')} strokeWidth={2} as={FiAlertTriangle} />
<Caption color={color('feedback-error')}>{getTitle()}</Caption>
</Stack>
);
});
export const TransactionsActions = memo((props: StackProps) => {
const handleSubmitPendingTransaction = useHandleSubmitPendingTransaction();
const signedTransaction = useSignedTransaction();
const { setShowNetworks } = useDrawers();
const error = useTransactionError();
const { setIsLoading, setIsIdle, isLoading } = useLoading(LOADING_KEYS.SUBMIT_TRANSACTION);
const handleSubmit = useCallback(async () => {
setIsLoading();
await handleSubmitPendingTransaction();
setIsIdle();
}, [setIsLoading, setIsIdle, handleSubmitPendingTransaction]);
return (
<Stack mt="auto" pt="loose" spacing="loose" bg={color('bg')} {...props}>
<Stack spacing="base-loose">
<SpaceBetween>
<Caption>Fees</Caption>
<Caption>
<FeeComponent />
</Caption>
</SpaceBetween>
<SpaceBetween>
<Caption>Network</Caption>
<NetworkRowItem onClick={() => setShowNetworks(true)} />
</SpaceBetween>
</Stack>
<MinimalErrorMessage />
<Button
borderRadius="12px"
py="base"
width="100%"
onClick={handleSubmit}
isLoading={isLoading}
isDisabled={!!error || !signedTransaction.value}
>
Confirm
</Button>
</Stack>
);
});

View File

@@ -1,10 +1,10 @@
import { useTxState } from '@common/hooks/use-tx-state';
import React from 'react';
import { RowItem } from '@components/transactions/row-item';
import { hexToHumanReadable } from '@common/utils';
import { useTransactionRequest } from '@common/hooks/use-transaction';
export const AttachmentRow: React.FC = () => {
const { pendingTransaction } = useTxState();
const pendingTransaction = useTransactionRequest();
return pendingTransaction?.attachment ? (
<RowItem name="Attachment" value={hexToHumanReadable(pendingTransaction.attachment)} />
) : null;

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useTxState } from '@common/hooks/use-tx-state';
import React, { memo } from 'react';
import { Stack, color, StackProps } from '@stacks/ui';
import { Divider } from '@components/divider';
import { deserializeCV, cvToString, getCVTypeString } from '@stacks/transactions';
@@ -8,35 +7,35 @@ import { useExplorerLink } from '@common/hooks/use-explorer-link';
import { Caption, Title } from '@components/typography';
import { ContractPreview } from '@components/transactions/contract-preview';
import { RowItem } from '@components/transactions/row-item';
import { useTransactionRequest } from '@common/hooks/use-transaction';
import { useLoadable } from '@common/hooks/use-loadable';
import { transactionFunctionsState } from '@store/transactions/contract-call';
interface ArgumentProps {
arg: string;
index: number;
}
const FunctionArgumentRow: React.FC<ArgumentProps> = ({ arg, index, ...rest }) => {
const { pendingTransactionFunction } = useTxState();
const FunctionArgumentRow: React.FC<ArgumentProps> = memo(({ arg, index, ...rest }) => {
const payload = useLoadable(transactionFunctionsState);
const argCV = deserializeCV(Buffer.from(arg, 'hex'));
const strValue = cvToString(argCV);
const name =
pendingTransactionFunction.state === 'hasValue'
? pendingTransactionFunction.contents?.args[index].name
: null;
const name = payload.value?.args[index].name || null;
return <RowItem name={name} type={getCVTypeString(argCV)} value={strValue} {...rest} />;
};
});
const FunctionArgumentsList = (props: StackProps) => {
const { pendingTransaction } = useTxState();
const FunctionArgumentsList = memo((props: StackProps) => {
const transactionRequest = useTransactionRequest();
if (!pendingTransaction || pendingTransaction.txType !== 'contract_call') {
if (!transactionRequest || transactionRequest.txType !== 'contract_call') {
return null;
}
const hasArgs = pendingTransaction.functionArgs.length > 0;
const hasArgs = transactionRequest.functionArgs.length > 0;
return (
<Stack divider={<Divider />} spacing="base" {...props}>
{hasArgs ? (
pendingTransaction.functionArgs.map((arg, index) => {
transactionRequest.functionArgs.map((arg, index) => {
return <FunctionArgumentRow key={`${arg}-${index}`} arg={arg} index={index} />;
})
) : (
@@ -44,14 +43,13 @@ const FunctionArgumentsList = (props: StackProps) => {
)}
</Stack>
);
};
});
export const ContractCallDetails: React.FC = () => {
const { pendingTransaction } = useTxState();
export const ContractCallDetails = memo(() => {
const transactionRequest = useTransactionRequest();
const { handleOpenTxLink } = useExplorerLink();
if (!pendingTransaction || pendingTransaction.txType !== 'contract_call') {
return null;
}
if (!transactionRequest || transactionRequest.txType !== 'contract_call') return null;
const { contractAddress, contractName, functionName, attachment } = transactionRequest;
return (
<Stack
@@ -67,19 +65,15 @@ export const ContractCallDetails: React.FC = () => {
</Title>
<ContractPreview
onClick={() =>
handleOpenTxLink(
`${pendingTransaction.contractAddress}.${pendingTransaction.contractName}`
)
}
contractAddress={pendingTransaction.contractAddress}
contractName={pendingTransaction.contractName}
functionName={pendingTransaction.functionName}
onClick={() => handleOpenTxLink(`${contractAddress}.${contractName}`)}
contractAddress={contractAddress}
contractName={contractName}
functionName={functionName}
/>
<Stack divider={<Divider />} spacing="base">
<FunctionArgumentsList />
{pendingTransaction?.attachment ? <AttachmentRow /> : null}
{attachment && <AttachmentRow />}
</Stack>
</Stack>
);
};
});

View File

@@ -8,6 +8,7 @@ import { Caption, Title } from '@components/typography';
import { Divider } from '@components/divider';
import { ContractPreview } from '@components/transactions/contract-preview';
import { RowItem } from '@components/transactions/row-item';
import { useTransactionRequest } from '@common/hooks/use-transaction';
function ContractCodeSection() {
const { pendingTransaction } = useTxState();
@@ -50,7 +51,7 @@ function TabButton({ isActive, ...props }: { isActive?: boolean } & BoxProps) {
}
export const ContractDeployDetails: React.FC = () => {
const { pendingTransaction } = useTxState();
const pendingTransaction = useTransactionRequest();
const { currentAccount, currentAccountStxAddress } = useWallet();
const [tab, setTab] = useState<'details' | 'code'>('details');
if (

View File

@@ -0,0 +1,77 @@
import { Box, BoxProps, color, Stack, StackProps } from '@stacks/ui';
import { Caption, Text } from '@components/typography';
import React, { memo } from 'react';
import { FiAlertTriangle } from 'react-icons/fi';
function ErrorButton({ variant, ...props }: { variant?: 'secondary' } & BoxProps) {
return (
<Caption
as="button"
border={0}
borderRadius="12px"
px="base"
py="base"
color={color('text-title')}
bg={variant === 'secondary' ? 'transparent' : color('bg-4')}
fontWeight={variant === 'secondary' ? 400 : 500}
{...props}
/>
);
}
export interface ErrorMessageProps extends StackProps {
title: string;
body: string | JSX.Element;
actions?: {
onClick: () => void;
label: string;
variant?: 'secondary';
}[];
}
export const ErrorMessage = memo(({ title, body, actions, ...rest }: ErrorMessageProps) => {
return (
<Stack
mt="loose"
p="loose"
borderRadius="12px"
border="4px solid #FCEEED"
spacing="extra-loose"
color={color('feedback-error')}
{...rest}
>
<Stack spacing="base-loose">
<Stack alignItems="center" isInline>
<Box strokeWidth={2} as={FiAlertTriangle} />
<Text color="currentColor">{title}</Text>
</Stack>
<Caption color={color('text-body')}>{body}</Caption>
</Stack>
<Stack spacing="loose">
{actions && (
<Stack isInline flexWrap="wrap">
{actions.map(action => (
<ErrorButton
flexGrow={1}
borderRadius="12px"
onClick={action.onClick}
variant={action.variant}
>
{action.label}
</ErrorButton>
))}
</Stack>
)}
<ErrorButton
p={0}
flexGrow={1}
borderRadius="12px"
onClick={() => window.close()}
variant="secondary"
>
Close window
</ErrorButton>
</Stack>
</Stack>
);
});

View File

@@ -0,0 +1,23 @@
import React, { memo } from 'react';
import { useSignedTransaction } from '@common/hooks/use-transaction';
import { LoadingRectangle } from '@components/loading-rectangle';
import { AuthType } from '@stacks/transactions';
import { stacksValue } from '@common/stacks-utils';
export const FeeComponent = memo(() => {
const signedTransaction = useSignedTransaction();
if (signedTransaction.isLoading) return <LoadingRectangle width="100px" height="14px" />;
if (!signedTransaction.value) return null;
const sponsored = signedTransaction.value.auth.authType === AuthType.Sponsored;
const value = signedTransaction.value.auth.spendingCondition?.fee?.toNumber() || 0;
return (
<>
{sponsored
? '🎉 sponsored'
: stacksValue({
value,
fixedDecimals: true,
})}
</>
);
});

View File

@@ -0,0 +1,27 @@
import React, { memo } from 'react';
import { useTransactionRequest } from '@common/hooks/use-transaction';
import { useOrigin } from '@common/hooks/use-origin';
import { useTransactionPageTitle } from '@common/hooks/use-transaction-page-title';
import { Stack } from '@stacks/ui';
import { Caption, Title } from '@components/typography';
export const TransactionPageTop = memo(() => {
const transactionRequest = useTransactionRequest();
const origin = useOrigin();
const pageTitle = useTransactionPageTitle();
if (!transactionRequest) return null;
const appName = transactionRequest?.appDetails?.name;
return (
<Stack pt="extra-loose" spacing="base">
<Title fontWeight="bold" as="h1">
{pageTitle}
</Title>
{appName ? (
<Caption>
Requested by {appName} {origin ? `(${origin?.split('//')[1]})` : null}
</Caption>
) : null}
</Stack>
);
});

View File

@@ -1,12 +1,6 @@
import React from 'react';
import React, { memo, useMemo } from 'react';
import { Box, Circle, color, Flex, Stack } from '@stacks/ui';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
currentPostConditionIndexStore,
pendingTransactionStore,
postConditionsStore,
showTxDetails,
} from '@store/transaction';
import { useRecoilValue } from 'recoil';
import { PostConditionComponent } from './single';
import { TransactionTypes } from '@stacks/connect';
import { stacksValue } from '@common/stacks-utils';
@@ -14,28 +8,12 @@ import { IconLock } from '@tabler/icons';
import { Body } from '@components/typography';
import { TransactionEventCard } from '@components/transactions/event-card';
import { truncateMiddle } from '@stacks/ui-utils';
import { useSelectedAsset } from '@common/hooks/use-selected-asset';
function usePostconditionsState() {
const showDetails = useRecoilValue(showTxDetails);
const pendingTransaction = useRecoilValue(pendingTransactionStore);
const setShowDetails = useSetRecoilState(showTxDetails);
const postConditions = useRecoilValue(postConditionsStore);
const setCurrentPostConditionIndex = useSetRecoilState(currentPostConditionIndexStore);
const { handleUpdateSelectedAsset } = useSelectedAsset();
return {
showDetails,
pendingTransaction,
setShowDetails,
postConditions,
setCurrentPostConditionIndex,
handleUpdateSelectedAsset,
};
}
import { useLoadable } from '@common/hooks/use-loadable';
import { postConditionsState } from '@store/transactions';
import { useTransactionRequest } from '@common/hooks/use-transaction';
function StxPostcondition() {
const { pendingTransaction } = usePostconditionsState();
const pendingTransaction = useTransactionRequest();
if (!pendingTransaction || pendingTransaction.txType !== TransactionTypes.STXTransfer)
return null;
return (
@@ -70,11 +48,12 @@ function NoPostconditions() {
);
}
function PostConditionsList() {
const { postConditions } = usePostconditionsState();
const PostConditionsList = memo(() => {
const postConditions = useRecoilValue(postConditionsState);
return (
<>
{postConditions.map((pc, index) => (
{postConditions?.map((pc, index) => (
<PostConditionComponent
pc={pc}
key={`${pc.type}-${pc.conditionCode}`}
@@ -83,16 +62,20 @@ function PostConditionsList() {
))}
</>
);
}
export const PostConditions: React.FC = () => {
const { pendingTransaction, postConditions } = usePostconditionsState();
const hasPostConditions = postConditions.length > 0;
});
export const PostConditions: React.FC = memo(() => {
const { value: postConditions } = useLoadable(postConditionsState);
const pendingTransaction = useTransactionRequest();
const hasPostConditions = useMemo(
() => postConditions && postConditions?.length > 0,
[postConditions]
);
const isStxTransfer =
pendingTransaction?.txType === TransactionTypes.STXTransfer && !hasPostConditions;
if (!postConditions || !pendingTransaction) return <></>;
return (
<Flex
border="4px solid"
@@ -111,4 +94,4 @@ export const PostConditions: React.FC = () => {
)}
</Flex>
);
};
});

View File

@@ -5,7 +5,6 @@ import { addressToString, PostCondition } from '@stacks/transactions';
import { truncateMiddle } from '@stacks/ui-utils';
import { ftDecimals, getPostConditionTitle } from '@common/stacks-utils';
import { usePendingTransaction } from '@common/hooks/use-pending-transaction';
import { TransactionEventCard } from '@components/transactions/event-card';
import { useCurrentAccount } from '@common/hooks/use-current-account';
import {
@@ -15,6 +14,7 @@ import {
getSymbolFromPostCondition,
useAssetInfoFromPostCondition,
} from '@common/postcondition-utils';
import { useTransactionRequest } from '@common/hooks/use-transaction';
interface PostConditionProps {
pc: PostCondition;
@@ -24,7 +24,7 @@ interface PostConditionProps {
export const PostConditionComponent: React.FC<PostConditionProps> = ({ pc, isLast }) => {
const { stxAddress } = useCurrentAccount();
const asset = useAssetInfoFromPostCondition(pc);
const pendingTransaction = usePendingTransaction();
const pendingTransaction = useTransactionRequest();
const title = getPostConditionTitle(pc);
const iconString = getIconStringFromPostCondition(pc);
const _ticker = getSymbolFromPostCondition(pc);

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { useTxState } from '@common/hooks/use-tx-state';
import { color, Stack } from '@stacks/ui';
import { AttachmentRow } from './attachment-row';
import { RowItem } from '@components/transactions/row-item';
import { Title } from '@components/typography';
import { Divider } from '@components/divider';
import { useTransactionRequest } from '@common/hooks/use-transaction';
export const StxTransferDetails: React.FC = () => {
const { pendingTransaction } = useTxState();
const pendingTransaction = useTransactionRequest();
if (!pendingTransaction || pendingTransaction.txType !== 'token_transfer') {
return null;
}

View File

@@ -0,0 +1,120 @@
import React, { memo } from 'react';
import { useCurrentAccount } from '@common/hooks/use-current-account';
import { color, Stack, useClipboard } from '@stacks/ui';
import { useTransactionRequest } from '@common/hooks/use-transaction';
import { useFetchBalances } from '@common/hooks/use-account-info';
import { Caption } from '@components/typography';
import { SpaceBetween } from '@components/space-between';
import { stacksValue } from '@common/stacks-utils';
import { STXTransferPayload, TransactionTypes } from '@stacks/connect';
import { useCurrentNetwork } from '@common/hooks/use-current-network';
import { truncateMiddle } from '@stacks/ui-utils';
import { useTxState } from '@common/hooks/use-tx-state';
import { ErrorMessage } from '@components/transactions/error';
import { useDrawers } from '@common/hooks/use-drawers';
export const FeeInsufficientFundsErrorMessage = memo(props => {
const currentAccount = useCurrentAccount();
const { setShowAccounts } = useDrawers();
const { onCopy, hasCopied } = useClipboard(currentAccount.address || '');
return (
<ErrorMessage
title="Insufficient balance"
body={`You do not have enough STX to cover the network fees for this transaction.`}
actions={[
{ onClick: () => setShowAccounts(true), label: 'Switch account' },
{ onClick: () => onCopy(), label: hasCopied ? 'Copied!' : 'Copy address' },
]}
{...props}
/>
);
});
export const StxTransferInsufficientFundsErrorMessage = memo(props => {
const pendingTransaction = useTransactionRequest();
const balances = useFetchBalances();
const currentAccount = useCurrentAccount();
const { setShowAccounts } = useDrawers();
const { onCopy, hasCopied } = useClipboard(currentAccount.address || '');
return (
<ErrorMessage
title="Insufficient balance"
body={
<Stack spacing="loose">
<Caption color={color('text-body')}>
You don't have enough STX to make this transfer. Send some STX to this address, or
switch to another account.
</Caption>
<Stack spacing="base" justifyContent="flex-end" textAlign="right">
<SpaceBetween>
<Caption>Current balance</Caption>
<Caption>
{balances?.value?.stx?.balance
? stacksValue({
value: balances?.value?.stx?.balance,
withTicker: true,
})
: '--'}
</Caption>
</SpaceBetween>
<SpaceBetween>
<Caption>Transfer amount</Caption>
<Caption>
{stacksValue({
value: (pendingTransaction as STXTransferPayload).amount,
withTicker: true,
})}
</Caption>
</SpaceBetween>
</Stack>
</Stack>
}
actions={[
{ onClick: () => setShowAccounts(true), label: 'Switch account' },
{ onClick: () => onCopy(), label: hasCopied ? 'Copied!' : 'Copy address' },
]}
{...props}
/>
);
});
export const NoContractErrorMessage = memo(props => {
const network = useCurrentNetwork();
const pendingTransaction = useTransactionRequest();
if (!pendingTransaction || pendingTransaction.txType !== TransactionTypes.ContractCall)
return null;
return (
<ErrorMessage
title="Contract not found"
body={`The contract (${truncateMiddle(pendingTransaction.contractAddress)}.${
pendingTransaction.contractName
}) that you are trying to call cannot be found on ${network.mode}.`}
actions={[{ onClick: () => window.close(), label: 'Switch network' }]}
{...props}
/>
);
});
export const UnauthorizedErrorMessage = memo(props => {
return (
<ErrorMessage
title="Unauthorized request"
body="The transaction request was not properly authorized by any of your accounts. If you've logged in to this app before, then you might need to re-authenticate into this application before attempting to sign a transaction with the Stacks Wallet."
{...props}
/>
);
});
export const BroadcastErrorMessage = memo(props => {
const { broadcastError } = useTxState();
if (!broadcastError) return null;
return (
<ErrorMessage
title="There was an error when broadcasting this transaction:"
body={broadcastError}
{...props}
/>
);
});

View File

@@ -124,6 +124,7 @@ export const Text = forwardRefWithAs<BoxProps, 'span'>((props, ref) => (
letterSpacing="-0.01em"
color={color('text-body')}
display="block"
lineHeight="1.5"
ref={ref}
{...props}
/>

View File

@@ -18,7 +18,6 @@ export const Unlock: React.FC = () => {
try {
await doUnlockWallet(password);
} catch (error) {
console.error(error);
setError('The password you entered is invalid.');
}
setLoading(false);

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Box, Text, Input, InputGroup, Button } from '@stacks/ui';
import { Formik } from 'formik';
import { useSetRecoilState } from 'recoil';
import { currentNetworkKeyStore, networksStore } from '@store/networks';
import { currentNetworkKeyState, networksState } from '@store/networks';
import { PopupContainer } from '@components/popup/container';
import { useDoChangeScreen } from '@common/hooks/use-do-change-screen';
import { ScreenPaths } from '@store/common/types';
@@ -15,8 +15,8 @@ export const AddNetwork: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const doChangeScreen = useDoChangeScreen();
const setNetworks = useSetRecoilState(networksStore);
const setNetworkKey = useSetRecoilState(currentNetworkKeyStore);
const setNetworks = useSetRecoilState(networksState);
const setNetworkKey = useSetRecoilState(currentNetworkKeyState);
return (
<PopupContainer

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
// @ts-nocheck TODO: actually use this page
=======
// @ts-nocheck
>>>>>>> 3805c0e8 (refactor: continue refactor)
import React, { Suspense, useEffect } from 'react';
import { Box, Button, Flex, Text, Input } from '@stacks/ui';
import { TxLoading } from '@pages/transaction';

View File

@@ -1,179 +1,27 @@
import React, { useMemo, useCallback, useState } from 'react';
import { Box, Button, color, Flex, Stack, StackProps } from '@stacks/ui';
import React, { memo } from 'react';
import { PopupHeader } from '@components/transactions/popup-header';
import { PopupContainer } from '@components/popup/container';
import { LoadingRectangle } from '@components/loading-rectangle';
import { useTxState } from '@common/hooks/use-tx-state';
import { useFetchBalances } from '@common/hooks/use-account-info';
import { useSetupTx } from '@common/hooks/use-setup-tx';
import { stacksValue } from '@common/stacks-utils';
import { TransactionsActions } from '@components/transactions/actions';
import { TransactionError } from './transaction-error';
import { TransactionPageTop } from '@components/transactions/page-top';
import { ContractCallDetails } from '@components/transactions/contract-call-details';
import { StxTransferDetails } from '@components/transactions/stx-transfer-details';
import { ContractDeployDetails } from '@components/transactions/contract-deploy-details';
import { PostConditions } from '@components/transactions/post-conditions/list';
import { showTxDetails } from '@store/transaction';
import { useRecoilValue } from 'recoil';
import { TransactionTypes } from '@stacks/connect';
import { useWallet } from '@common/hooks/use-wallet';
import { TransactionError, TransactionErrorReason } from './transaction-error';
import { AuthType } from '@stacks/transactions';
import BigNumber from 'bignumber.js';
import { Caption, Title } from '@components/typography';
import { useOrigin } from '@common/hooks/use-origin';
import { SpaceBetween } from '@components/space-between';
import { useDrawers } from '@common/hooks/use-drawers';
import { PopupHeader } from '@components/transactions/popup-header';
import { NetworkRowItem } from '@components/network-row-item';
import { StxTransferDetails } from '@components/transactions/stx-transfer-details';
import { useTransactionRequest } from '@common/hooks/use-transaction';
export const TxLoading: React.FC = () => {
export const TransactionPage = memo(() => {
const transactionRequest = useTransactionRequest();
if (!transactionRequest) return null;
return (
<Flex flexDirection="column" mt="extra-loose">
<Box width="100%">
<LoadingRectangle width="60%" height="24px" />
</Box>
<Box width="100%" mt="base">
<LoadingRectangle width="40%" height="16px" />
</Box>
</Flex>
);
};
export const FeeValue = () => {
const { signedTransaction: tx } = useTxState();
if (tx.state === 'loading' && !tx.value) return <LoadingRectangle width="100px" height="14px" />;
if (!tx.value) return null;
const sponsored = tx.value.auth.authType === AuthType.Sponsored;
return (
<>
{sponsored
? '🎉 sponsored'
: stacksValue({
value: tx.value.auth.spendingCondition?.fee?.toNumber() || 0,
fixedDecimals: true,
})}
</>
);
};
function useTransactionError() {
const { pendingTransaction, broadcastError, isUnauthorizedTransaction } = useTxState();
const { currentAccount } = useWallet();
const balances = useFetchBalances();
return useMemo<TransactionErrorReason | void>(() => {
if (isUnauthorizedTransaction) return TransactionErrorReason.Unauthorized;
if (!pendingTransaction || balances.errorMaybe() || !currentAccount) {
return TransactionErrorReason.Generic;
}
if (broadcastError) return TransactionErrorReason.BroadcastError;
if (balances.value) {
const stxBalance = new BigNumber(balances.value.stx.balance);
if (pendingTransaction.txType === TransactionTypes.STXTransfer) {
const transferAmount = new BigNumber(pendingTransaction.amount);
if (transferAmount.gte(stxBalance)) {
return TransactionErrorReason.StxTransferInsufficientFunds;
}
}
}
return;
}, [balances, currentAccount, pendingTransaction, broadcastError, isUnauthorizedTransaction]);
}
function useTransactionPageTitle() {
const { pendingTransaction } = useTxState();
const txType = pendingTransaction?.txType;
return useMemo(() => {
if (!pendingTransaction) return;
if (txType === TransactionTypes.STXTransfer) return 'Confirm transfer';
if (txType === TransactionTypes.ContractDeploy) return 'Deploy contract';
if (txType === TransactionTypes.ContractCall && 'functionName' in pendingTransaction)
return pendingTransaction.functionName || 'Sign transaction';
return 'Sign transaction';
}, [pendingTransaction, txType]);
}
export const TransactionPage: React.FC = () => {
const isSetup = useSetupTx();
const { pendingTransaction } = useTxState();
const { currentAccount } = useWallet();
const origin = useOrigin();
const pageTitle = useTransactionPageTitle();
const error = useTransactionError();
const showDetails = useRecoilValue(showTxDetails);
const appName = pendingTransaction?.appDetails?.name;
if (!isSetup) {
// loading state
return <></>;
}
if (error !== undefined) {
return <TransactionError reason={error} />;
}
if (!currentAccount || !pendingTransaction) throw new Error('Invalid code path.');
return (
<PopupContainer header={<PopupHeader />} requestType="transaction">
<Stack pt="extra-loose" spacing="base">
<Title fontWeight="bold" as="h1">
{pageTitle}
</Title>
{appName ? (
<Caption>
Requested by {appName} {origin ? `(${origin?.split('//')[1]})` : null}
</Caption>
) : null}
</Stack>
<PopupContainer header={<PopupHeader />}>
<TransactionPageTop />
<TransactionError />
<PostConditions />
{showDetails && (
<>
<ContractCallDetails />
<StxTransferDetails />
<ContractDeployDetails />
</>
)}
<ActionBar />
<ContractCallDetails />
<StxTransferDetails />
<ContractDeployDetails />
<TransactionsActions />
</PopupContainer>
);
};
function ActionBar(props: StackProps) {
const { signedTransaction, doSubmitPendingTransaction } = useTxState();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
await doSubmitPendingTransaction();
setIsSubmitting(false);
}, [setIsSubmitting, doSubmitPendingTransaction]);
const { setShowNetworks } = useDrawers();
return (
<Stack mt="auto" pt="loose" spacing="loose" bg={color('bg')} {...props}>
<Stack spacing="base-loose">
<SpaceBetween>
<Caption>Fees</Caption>
<Caption>
<FeeValue />
</Caption>
</SpaceBetween>
<SpaceBetween>
<Caption>Network</Caption>
<NetworkRowItem onClick={() => setShowNetworks(true)} />
</SpaceBetween>
</Stack>
<Button
borderRadius="12px"
py="base"
width="100%"
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={!signedTransaction.value}
>
Confirm
</Button>
</Stack>
);
}
});

View File

@@ -1,120 +1,37 @@
import React from 'react';
import { PopupContainer } from '@components/popup/container';
import { Box, Text, Button } from '@stacks/ui';
import { useTxState } from '@common/hooks/use-tx-state';
import { STXTransferPayload } from '@stacks/connect';
import { stacksValue } from '@common/stacks-utils';
import { useFetchBalances } from '@common/hooks/use-account-info';
import { LoadingRectangle } from '@components/loading-rectangle';
import { useWallet } from '@common/hooks/use-wallet';
import { Body } from '@components/typography';
import { Header } from '@components/header';
import React, { memo } from 'react';
import { useTransactionError } from '@common/hooks/use-transaction-error';
import {
BroadcastErrorMessage,
FeeInsufficientFundsErrorMessage,
NoContractErrorMessage,
StxTransferInsufficientFundsErrorMessage,
UnauthorizedErrorMessage,
} from '@components/transactions/transaction-errors';
export enum TransactionErrorReason {
StxTransferInsufficientFunds = 0,
FeeInsufficientFunds = 1,
Generic = 2,
BroadcastError = 3,
Unauthorized = 4,
StxTransferInsufficientFunds = 1,
FeeInsufficientFunds = 2,
Generic = 3,
BroadcastError = 4,
Unauthorized = 5,
NoContract = 6,
}
interface TransactionErrorProps {
reason: TransactionErrorReason;
}
export const TransactionError: React.FC<TransactionErrorProps> = ({ reason }) => {
const { pendingTransaction, broadcastError } = useTxState();
const { currentAccountDisplayName, currentAccountStxAddress, currentNetwork } = useWallet();
const balances = useFetchBalances();
return (
<PopupContainer header={<Header />}>
<Box mt="loose" />
{reason === TransactionErrorReason.StxTransferInsufficientFunds ? (
<Box>
<Text display="block" fontSize={2}>
You don't have enough STX to make this transfer. Try switching accounts, switching
networks, or adding STX to this account.
</Text>
<Box my="base">
<Text fontSize={2} fontWeight="500">
Transfer amount:
</Text>
<Text fontSize={2} ml="tight" fontFamily="mono">
{stacksValue({
value: (pendingTransaction as STXTransferPayload).amount,
withTicker: true,
})}
</Text>
</Box>
<Box my="base">
<Text fontSize={2} fontWeight="500">
Current balance:
</Text>
{balances.state === 'hasValue' && balances.contents ? (
<Text fontSize={2} ml="tight" fontFamily="mono">
{stacksValue({ value: balances.contents.stx.balance, withTicker: true })}
</Text>
) : (
<LoadingRectangle height="16px" width="50px" />
)}
</Box>
</Box>
) : null}
{reason === TransactionErrorReason.BroadcastError ? (
<Box>
<Text display="block" fontSize={2}>
There was an error when broadcasting this transaction:
</Text>
<Box my="base">
<Text fontSize={2} fontFamily="mono">
{broadcastError}
</Text>
</Box>
</Box>
) : null}
{reason === TransactionErrorReason.Unauthorized ? (
<Box mb="base">
<Body display="block" fontSize={2} my="loose">
The request to sign a transaction was not properly authorized by any of your accounts.
</Body>
<Body display="block" fontSize={2} my="loose">
If you've logged in to this app before, then you might need to re-authenticate into this
application before attempting to sign a transaction with the Stacks Wallet.
</Body>
</Box>
) : (
<>
<Box mb="base">
<Text fontSize={2} fontWeight="500">
Current Network:
</Text>
<Text fontSize={2} ml="tight" fontFamily="mono">
{currentNetwork.name}
</Text>
</Box>
<Box width="100%">
<Text
fontSize={2}
fontWeight="600"
fontFamily="heading"
color="ink.1000"
display="block"
>
{currentAccountDisplayName}
</Text>
<Text textStyle="body.small" color="ink.600">
{currentAccountStxAddress}
</Text>
</Box>
</>
)}
<Box flexGrow={1} />
<Box mt="extra-loose" mb="base">
<Button width="100%" onClick={() => window.close()}>
Close
</Button>
</Box>
</PopupContainer>
);
};
export const TransactionError = memo(() => {
const reason = useTransactionError();
if (!reason) return null;
switch (reason) {
case TransactionErrorReason.NoContract:
return <NoContractErrorMessage />;
case TransactionErrorReason.StxTransferInsufficientFunds:
return <StxTransferInsufficientFundsErrorMessage />;
case TransactionErrorReason.BroadcastError:
return <BroadcastErrorMessage />;
case TransactionErrorReason.FeeInsufficientFunds:
return <FeeInsufficientFundsErrorMessage />;
case TransactionErrorReason.Unauthorized:
return <UnauthorizedErrorMessage />;
default:
return null;
}
});

View File

@@ -1,42 +1,40 @@
import { atom, selector } from 'recoil';
import { currentNetworkStore, currentTransactionVersion } from '@store/networks';
import { MempoolTransaction, Transaction } from '@blockstack/stacks-blockchain-api-types';
import { Account, getStxAddress } from '@stacks/wallet-sdk';
import { walletState } from '@store/wallet';
import { apiRevalidation, intervalStore } from '@store/common/api';
import { fetchAllAccountData } from '@common/api/accounts';
import { atom, selector, waitForAll } from 'recoil';
import BN from 'bn.js';
import type { AllAccountData } from '@common/api/accounts';
import { fetchAllAccountData } from '@common/api/accounts';
import { apiRevalidation, intervalStore } from '@store/common/api-helpers';
import { fetcher } from '@common/wrapped-fetch';
import { transactionRequestStxAddressState } from '@store/transactions/requests';
import { currentNetworkState, networkTransactionVersionState } from '@store/networks';
import { DEFAULT_POLLING_INTERVAL } from '@store/common/constants';
import { walletState } from '@store/wallet';
export const currentAccountIndexStore = atom<number | undefined>({
key: 'wallet.current-account-index',
default: undefined,
});
export const currentAccountStore = selector({
key: 'wallet.current-account',
get: ({ get }) => {
const accountIndex = get(currentAccountIndexStore);
const wallet = get(walletState);
if (accountIndex === undefined || !wallet) {
return undefined;
}
return wallet.accounts[accountIndex];
},
});
export const currentAccountStxAddressStore = selector({
key: 'wallet.current-stx-address',
get: ({ get }) => {
const account = get(currentAccountStore);
if (!account) return undefined;
const transactionVersion = get(currentTransactionVersion);
return getStxAddress({ account, transactionVersion });
},
});
/**
* --------------------------------------
* Overview
* --------------------------------------
*
* accountsState -- the array of accounts from the wallet state, uses `walletState`
* accountsWithAddressState - a mapped array of the above state with the current network mode stx address included
* currentAccountIndexStore - the last selected index by the user (or default 0)
* hasSwitchedAccountsState - a toggle for when a user switches accounts during a pending transaction request
* transactionAccountIndexState - if `stxAccount` is passed with a transaction request, this is the index of that address
* currentAccountState - the current active account, either the account associated with a pending transaction request, or the one selected by the user
* currentAccountStxAddressState - a selector for the address of the current account
* accountDataState -- all external API data for the selected STX address
* accountBalancesState - a selector for the balances from `accountDataState`
* accountInfoStore - external API data from the `v2/accounts` endpoint, should be the most up-to-date
*/
//--------------------------------------
// All accounts
//--------------------------------------
export const accountsState = selector<Account[] | undefined>({
key: 'wallet.accounts',
key: 'accounts.base',
get: ({ get }) => {
const wallet = get(walletState);
if (!wallet) return undefined;
@@ -44,50 +42,134 @@ export const accountsState = selector<Account[] | undefined>({
},
});
export const accountBalancesStore = selector({
key: 'api.account-balances',
export type AccountWithAddress = Account & { address: string };
// map through the accounts and get the address for the current network mode (testnet|mainnet)
export const accountsWithAddressState = selector<AccountWithAddress[] | undefined>({
key: 'accounts.with-address',
get: ({ get }) => {
const accountData = get(accountDataStore);
return accountData?.balances;
const accounts = get(accountsState);
const transactionVersion = get(networkTransactionVersionState);
if (!accounts) return undefined;
return accounts.map(account => {
const address = getStxAddress({ account, transactionVersion });
return {
...account,
address,
};
});
},
});
export const accountDataStore = selector({
key: 'api.account-data',
get: async ({ get }) => {
get(apiRevalidation);
get(intervalStore(DEFAULT_POLLING_INTERVAL));
const { url } = get(currentNetworkStore);
const address = get(currentAccountStxAddressStore);
if (!address) {
console.error('Cannot get account info when logged out.');
return;
//--------------------------------------
// Current account
//--------------------------------------
// The index of the current account
// persists through sessions (viewings)
export const currentAccountIndexStore = atom<number | undefined>({
key: 'wallet.current-account-index',
default: undefined,
});
// This is only used when there is a pending transaction request and
// the user switches accounts during the signing process
export const hasSwitchedAccountsState = atom<boolean>({
key: 'account.has-switched',
default: false,
});
// if there is a pending transaction that has a stxAccount param
// find the index from the accounts atom and return it
export const transactionAccountIndexState = selector<number | undefined>({
key: 'account.transaction-account-index',
get: ({ get }) => {
const { accounts, txAddress } = get(
waitForAll({
accounts: accountsWithAddressState,
txAddress: transactionRequestStxAddressState,
})
);
if (txAddress && accounts) {
const selectedAccount = accounts.findIndex(account => account.address === txAddress);
if (selectedAccount) return selectedAccount;
}
return undefined;
},
});
// This contains the state of the current account:
// could be the account associated with an in-process transaction request
// or the last selected / first account of the user
export const currentAccountState = selector<AccountWithAddress | undefined>({
key: 'account.current',
get: ({ get }) => {
const { accountIndex, txIndex, hasSwitched, accounts } = get(
waitForAll({
accountIndex: currentAccountIndexStore,
txIndex: transactionAccountIndexState,
hasSwitched: hasSwitchedAccountsState,
accounts: accountsWithAddressState,
})
);
if (!accounts) return undefined;
if (typeof txIndex === 'number' && !hasSwitched) return accounts[txIndex];
if (typeof accountIndex === 'number') return accounts[accountIndex];
return undefined;
},
dangerouslyAllowMutability: true,
});
// gets the address of the current account (in the current network mode)
export const currentAccountStxAddressState = selector<string | undefined>({
key: 'account.current-address',
get: ({ get }) => get(currentAccountState)?.address,
});
// external API data associated with the current account's address
export const accountDataState = selector<AllAccountData | undefined>({
key: 'account.data',
get: async ({ get }) => {
const { network, address } = get(
waitForAll({
apiRevalidation,
interval: intervalStore(DEFAULT_POLLING_INTERVAL),
network: currentNetworkState,
address: currentAccountStxAddressState,
})
);
if (!address) return;
try {
return fetchAllAccountData(url)(address);
return fetchAllAccountData(network.url)(address);
} catch (error) {
console.error(error);
console.error(`Unable to fetch account data from ${url}`);
console.error(`Unable to fetch account data from ${network.url}`);
return;
}
},
});
export const accountInfoStore = selector<{ balance: BN; nonce: number }>({
key: 'wallet.account-info',
// the balances of the current account's address
export const accountBalancesState = selector<AllAccountData['balances'] | undefined>({
key: 'account.balances',
get: ({ get }) => get(accountDataState)?.balances,
});
// the raw account info from the `v2/accounts` endpoint, should be most up-to-date info (compared to the extended API)
export const accountInfoStore = selector<undefined | { balance: BN; nonce: number }>({
key: 'account.info',
get: async ({ get }) => {
get(apiRevalidation);
get(intervalStore(DEFAULT_POLLING_INTERVAL));
const address = get(currentAccountStxAddressStore);
if (!address) {
throw new Error('Cannot get account info when logged out.');
}
const network = get(currentNetworkStore);
const { address, network } = get(
waitForAll({
revalidation: apiRevalidation,
interval: intervalStore(DEFAULT_POLLING_INTERVAL),
address: currentAccountStxAddressState,
network: currentNetworkState,
})
);
if (!address) return;
const url = `${network.url}/v2/accounts/${address}`;
const error = new Error(`Unable to fetch account info from ${url}`);
const response = await fetcher(url, {
credentials: 'omit',
});
const response = await fetcher(url);
if (!response.ok) throw error;
const data = await response.json();
return {
@@ -97,10 +179,11 @@ export const accountInfoStore = selector<{ balance: BN; nonce: number }>({
},
});
export const accountTransactionsState = selector({
key: 'activity.transactions',
// combo of pending and confirmed transactions for the current address
export const accountTransactionsState = selector<(MempoolTransaction | Transaction)[]>({
key: 'account.all-transactions',
get: async ({ get }) => {
const data = get(accountDataStore);
const data = get(accountDataState);
const transactions = data?.transactions?.results || [];
const pending = data?.pendingTransactions || [];
return [...pending, ...transactions];

View File

@@ -4,7 +4,7 @@ import { getStxAddress } from '@stacks/wallet-sdk';
import { fetcher } from '@common/wrapped-fetch';
import { accountsState } from './index';
import { currentNetworkStore, currentTransactionVersion } from '@store/networks';
import { currentNetworkState, networkTransactionVersionState } from '@store/networks';
async function fetchNamesByAddress(networkUrl: string, address: string): Promise<string[]> {
const res = await fetcher(networkUrl + `/v1/addresses/stacks/${address}`);
@@ -42,8 +42,8 @@ export const accountNameState = selector<AccountNameState>({
key: 'names',
get: async ({ get }) => {
const accounts = get(accountsState);
const network = get(currentNetworkStore);
const transactionVersion = get(currentTransactionVersion);
const network = get(currentNetworkState);
const transactionVersion = get(networkTransactionVersionState);
if (!network || !accounts) return null;

View File

@@ -1,8 +1,8 @@
import { atomFamily, selector, waitForAll } from 'recoil';
import { localStorageEffect } from '@store/common/utils';
import { currentNetworkStore } from '@store/networks';
import { accountDataStore, currentAccountStxAddressStore } from '@store/accounts/index';
import { apiRevalidation } from '@store/common/api';
import { currentNetworkState } from '@store/networks';
import { accountDataState, currentAccountStxAddressState } from '@store/accounts/index';
import { apiRevalidation } from '@store/common/api-helpers';
import { accountInfoStore } from '@store/accounts/index';
export const latestNoncesState = atomFamily<
@@ -20,8 +20,8 @@ export const latestNoncesState = atomFamily<
export const latestNonceState = selector({
key: 'wallet.latest-nonce',
get: ({ get }) => {
const network = get(currentNetworkStore);
const address = get(currentAccountStxAddressStore);
const network = get(currentNetworkState);
const address = get(currentAccountStxAddressState);
return get(latestNoncesState([network.url, address || '']));
},
});
@@ -35,8 +35,8 @@ export const correctNonceState = selector({
waitForAll({
account: accountInfoStore,
lastConfirmedTx: latestNonceState,
accountData: accountDataStore,
address: currentAccountStxAddressStore,
accountData: accountDataState,
address: currentAccountStxAddressState,
})
);

View File

@@ -1,6 +1,6 @@
import { selector, selectorFamily } from 'recoil';
import { currentNetworkStore } from '@store/networks';
import { accountBalancesStore } from '@store/accounts';
import { currentNetworkState } from '@store/networks';
import { accountBalancesState } from '@store/accounts';
import {
fetchFungibleTokenMetaData,
fetchSip10Status,
@@ -24,7 +24,7 @@ export const assetSip10ImplementationState = selectorFamily<
get:
({ contractName, contractAddress }) =>
async ({ get }) => {
const network = get(currentNetworkStore);
const network = get(currentNetworkState);
const chain = getNetworkChain(network);
try {
return fetchSip10Status({
@@ -55,7 +55,7 @@ export const assetMetaDataState = selectorFamily<
})
);
if (isImplemented || isImplemented === null) {
const network = get(currentNetworkStore);
const network = get(currentNetworkState);
const localData = getLocalData(network.url, contractAddress, contractName);
if (localData) {
return {
@@ -83,7 +83,7 @@ export const assetMetaDataState = selectorFamily<
export const assetsState = selector<AssetWithMeta[] | undefined>({
key: 'assets',
get: async ({ get }) => {
const balance = get(accountBalancesStore);
const balance = get(accountBalancesState);
if (!balance) return;
const assets = transformAssets(balance);
const _assets: AssetWithMeta[] = (await Promise.all(
@@ -128,7 +128,7 @@ export const nonFungibleTokensState = selector({
export const stxTokenState = selector({
key: 'assets.stx',
get: ({ get }) => {
const balances = get(accountBalancesStore);
const balances = get(accountBalancesState);
if (!balances || balances.stx.balance === '0') return;
return {
type: 'stx',

View File

@@ -1,9 +1,9 @@
import { Configuration, SmartContractsApi } from '@stacks/blockchain-api-client';
import { ChainID, cvToString, hexToCV } from '@stacks/transactions';
import { SIP_010 } from '@common/constants';
import { Asset, FungibleTokenOptions } from '@store/assets/types';
import { Network } from '@store/networks';
import { AddressBalanceResponse } from '@blockstack/stacks-blockchain-api-types';
import type { Asset, FungibleTokenOptions } from '@store/assets/types';
import type { Network } from '@common/constants';
import type { AddressBalanceResponse } from '@blockstack/stacks-blockchain-api-types';
import { getAssetStringParts, truncateMiddle } from '@stacks/ui-utils';
export async function callReadOnlyFunction({

View File

@@ -0,0 +1,50 @@
import { selector } from 'recoil';
import { currentNetworkState } from '@store/networks';
import {
Configuration,
AccountsApi,
SmartContractsApi,
InfoApi,
BlocksApi,
} from '@stacks/blockchain-api-client';
import { fetcher } from '@common/wrapped-fetch';
export const apiClientConfiguration = selector({
key: 'clients.config',
get: ({ get }) => {
const network = get(currentNetworkState);
return new Configuration({ basePath: network.url, fetchApi: fetcher });
},
});
export const smartContractClientState = selector({
key: 'clients.smart-contract',
get: ({ get }) => {
const config = get(apiClientConfiguration);
return new SmartContractsApi(config);
},
});
export const accountsApiClientState = selector({
key: 'clients.accounts',
get: ({ get }) => {
const config = get(apiClientConfiguration);
return new AccountsApi(config);
},
});
export const infoApiClientState = selector({
key: 'clients.info',
get: ({ get }) => {
const config = get(apiClientConfiguration);
return new InfoApi(config);
},
});
export const blocksApiClientState = selector({
key: 'clients.blocks',
get: ({ get }) => {
const config = get(apiClientConfiguration);
return new BlocksApi(config);
},
});

View File

@@ -1 +1 @@
export const DEFAULT_POLLING_INTERVAL = 5000;
export const DEFAULT_POLLING_INTERVAL = 30000;

View File

@@ -62,3 +62,10 @@ export const localStorageEffect =
});
}
};
export function textToBytes(content: string) {
return new TextEncoder().encode(content);
}
export function bytesToText(buffer: Uint8Array) {
return new TextDecoder().decode(buffer);
}

View File

@@ -1,89 +1,64 @@
import { atom, selector } from 'recoil';
import { atom, selector, waitForAll } from 'recoil';
import { localStorageEffect } from './common/utils';
import RPCClient from '@stacks/rpc-client';
import { ChainID, TransactionVersion } from '@stacks/transactions';
import { StacksNetwork, StacksTestnet, StacksMainnet } from '@stacks/network';
import { BlockListResponse, CoreNodeInfoResponse } from '@blockstack/stacks-blockchain-api-types';
import { fetchFromSidecar } from '@common/api/fetch';
import { fetcher } from '@common/wrapped-fetch';
import { apiRevalidation } from '@store/common/api';
import { apiRevalidation } from '@store/common/api-helpers';
import { transactionRequestNetwork } from '@store/transactions/requests';
import { findMatchingNetworkKey } from '@common/utils';
import { defaultNetworks, Networks } from '@common/constants';
import { blocksApiClientState, infoApiClientState } from '@store/common/api-clients';
export interface Network {
url: string;
name: string;
chainId: ChainID;
}
interface Networks {
[key: string]: Network;
}
export const defaultNetworks: Networks = {
mainnet: {
url: 'https://stacks-node-api.mainnet.stacks.co',
name: 'Mainnet',
chainId: ChainID.Mainnet,
},
testnet: {
url: 'https://stacks-node-api.testnet.stacks.co',
name: 'Testnet',
chainId: ChainID.Testnet,
},
regtest: {
url: 'https://stacks-node-api.regtest.stacks.co',
name: 'Regtest',
chainId: ChainID.Testnet,
},
localnet: {
url: 'http://localhost:3999',
name: 'Localnet',
chainId: ChainID.Testnet,
},
};
export const currentNetworkKeyStore = atom({
key: 'networks.current-key-v2',
default: 'mainnet',
effects_UNSTABLE: [localStorageEffect()],
});
export const networksStore = atom<Networks>({
key: 'networks.networks-v3',
export const networksState = atom<Networks>({
key: 'networks',
default: defaultNetworks,
effects_UNSTABLE: [localStorageEffect()],
});
export const currentNetworkStore = selector({
export const currentNetworkKeyState = atom({
key: 'networks.current-key',
default: selector({
key: 'networks.current-key.default',
get: ({ get }) => {
const { networks, txNetwork } = get(
waitForAll({
networks: networksState,
txNetwork: transactionRequestNetwork,
})
);
if (txNetwork && networks && Object.keys(networks).length > 0) {
const newKey = findMatchingNetworkKey(txNetwork, networks);
if (newKey) return newKey;
}
return 'mainnet';
},
}),
});
export const currentNetworkState = selector({
key: 'networks.current-network',
get: ({ get }) => {
const networks = get(networksStore);
const key = get(currentNetworkKeyStore);
const { networks, key } = get(
waitForAll({
networks: networksState,
key: currentNetworkKeyState,
})
);
return networks[key];
},
});
export const currentTransactionVersion = selector({
export const networkTransactionVersionState = selector({
key: 'networks.transaction-version',
get: ({ get }) => {
const network = get(currentNetworkStore);
return network.chainId === ChainID.Mainnet
get: ({ get }) =>
get(currentNetworkState).chainId === ChainID.Mainnet
? TransactionVersion.Mainnet
: TransactionVersion.Testnet;
},
});
export const rpcClientStore = selector({
key: 'networks.rpc-client',
get: ({ get }) => {
const network = get(currentNetworkStore);
return new RPCClient(network.url);
},
: TransactionVersion.Testnet,
});
export const stacksNetworkStore = selector<StacksNetwork>({
key: 'networks.stacks-network',
get: ({ get }) => {
const network = get(currentNetworkStore);
const network = get(currentNetworkState);
const stacksNetwork =
network.chainId === ChainID.Testnet ? new StacksTestnet() : new StacksMainnet();
stacksNetwork.coreApiUrl = network.url;
@@ -91,29 +66,20 @@ export const stacksNetworkStore = selector<StacksNetwork>({
},
});
export const latestBlockHeightStore = selector({
export const latestBlockHeightState = selector({
key: 'api.latest-block-height',
get: async ({ get }) => {
const { url } = get(currentNetworkStore);
const blocksResponse: BlockListResponse = await fetchFromSidecar(url)('/block');
const [block] = blocksResponse.results;
return block.height;
const client = get(blocksApiClientState);
const data = await client.getBlockList({});
return data?.results?.[0]?.height;
},
});
export const chainInfoStore = selector({
key: 'api.chain-info',
export const networkInfoState = selector({
key: 'api.network-info',
get: async ({ get }) => {
get(apiRevalidation);
const { url } = get(currentNetworkStore);
const infoUrl = `${url}/v2/info`;
try {
const res = await fetcher(infoUrl);
if (!res.ok) throw `Unable to fetch chain data from ${infoUrl}`;
const info: CoreNodeInfoResponse = await res.json();
return info;
} catch (error) {
throw `Unable to fetch chain data from ${infoUrl}`;
}
const client = get(infoApiClientState);
return client.getCoreApiInfo();
},
});

View File

@@ -9,217 +9,29 @@ import {
standardPrincipalCVFromAddress,
uintCV,
} from '@stacks/transactions';
import { TransactionPayload } from '@stacks/connect';
import { atom, selectorFamily } from 'recoil';
import {
ContractCallPayload,
ContractDeployPayload,
STXTransferPayload,
TransactionPayload,
TransactionTypes,
} from '@stacks/connect';
import { decodeToken } from 'jsontokens';
import { atom, selector, selectorFamily } from 'recoil';
import { generateTransaction } from '@common/transaction-utils';
import {
accountBalancesStore,
currentAccountStore,
currentAccountStxAddressStore,
accountBalancesState,
currentAccountState,
currentAccountStxAddressState,
} from '@store/accounts';
import { currentNetworkStore, rpcClientStore, stacksNetworkStore } from '@store/networks';
import { stacksNetworkStore } from '@store/networks';
import { selectedAssetStore } from './assets/asset-search';
import BN from 'bn.js';
import { stxToMicroStx } from '@common/stacks-utils';
import { getAssetStringParts } from '@stacks/ui-utils';
import { correctNonceState } from '@store/accounts/nonce';
export type TransactionPayloadWithAttachment = TransactionPayload & {
attachment?: string;
};
/** Transaction signing popup store */
export const showTxDetails = atom<boolean>({
key: 'transaction.show-details',
default: true,
});
export const requestTokenStore = atom<string | null>({
key: 'transaction.request-token',
default: null,
});
function getPayload(requestToken: string) {
if (!requestToken) return undefined;
const token = decodeToken(requestToken);
const payload = token.payload as unknown as TransactionPayloadWithAttachment;
if (payload.txType === TransactionTypes.ContractCall)
return payload as ContractCallPayload & {
attachment?: string;
};
if (payload.txType === TransactionTypes.ContractDeploy)
return payload as ContractDeployPayload & {
attachment?: string;
};
if (payload.txType === TransactionTypes.STXTransfer)
return payload as STXTransferPayload & {
attachment?: string;
};
return payload;
}
export const transactionPayloadStore = selector({
key: 'transaction.payload',
get: ({ get }) => {
const requestToken = get(requestTokenStore);
if (requestToken === null) return;
return getPayload(requestToken);
},
});
export const pendingTransactionStore = selector({
key: 'transaction.pending-transaction',
get: ({ get }) => {
const requestToken = get(requestTokenStore);
if (!requestToken) return undefined;
const tx = getPayload(requestToken);
if (!tx) return undefined;
const network = get(stacksNetworkStore);
const postConditions = get(postConditionsStore);
tx.postConditions = postConditions;
tx.network = network;
return tx;
},
});
export const contractSourceStore = selector({
key: 'transaction.contract-source',
get: async ({ get }) => {
const tx = get(pendingTransactionStore);
const rpcClient = get(rpcClientStore);
if (!tx) return '';
if (tx.txType === TransactionTypes.ContractCall)
return rpcClient.fetchContractSource({
contractName: tx.contractName,
contractAddress: tx.contractAddress,
});
if (tx.txType === TransactionTypes.ContractDeploy) return tx.codeBody;
return '';
},
});
export const contractInterfaceStore = selector({
key: 'transaction.contract-interface',
get: async ({ get }) => {
const payload = get(transactionPayloadStore);
const tx = get(pendingTransactionStore);
const network = get(currentNetworkStore);
const rpcClient = get(rpcClientStore);
if (!tx) {
return undefined;
}
if (payload?.network && payload.network.chainId !== network.chainId) return undefined;
if (tx.txType === TransactionTypes.ContractCall) {
try {
// TODO: replace with smartContract client from api client
return rpcClient.fetchContractInterface({
contractName: tx.contractName,
contractAddress: tx.contractAddress,
});
} catch (error) {
// TODO: fix race condition
// we don't need to throw here
// the network switch can happen before or after this,
// and the interface can sometimes fail before landing on the correct network
// @see https://github.com/blockstack/stacks-wallet-web/issues/1174
console.log(
`Unable to fetch interface for contract ${tx.contractAddress}.${tx.contractName}`
);
}
}
return undefined;
},
});
export const pendingTransactionFunctionSelector = selector({
key: 'transactions.pending-transaction-function',
get: ({ get }) => {
const pendingTransaction = get(pendingTransactionStore);
const contractInterface = get(contractInterfaceStore);
if (
!pendingTransaction ||
pendingTransaction.txType !== 'contract_call' ||
!contractInterface
) {
return undefined;
}
const selectedFunction = contractInterface.functions.find(func => {
return func.name === pendingTransaction.functionName;
});
if (!selectedFunction) {
throw new Error(
`Attempting to call a function (\`${pendingTransaction.functionName}\`) that ` +
`does not exist on contract ${pendingTransaction.contractAddress}.${pendingTransaction.contractName}`
);
}
return selectedFunction;
},
});
export const signedTransactionStore = selector({
key: 'transaction.signedTransaction',
get: async ({ get }) => {
const account = get(currentAccountStore);
if (!account) {
console.error('Unable to sign transaction when logged out.');
return undefined;
}
const pendingTransaction = get(pendingTransactionStore);
if (!pendingTransaction) {
console.error('Unable to get signed transaction - no pending transaction found.');
return undefined;
}
const nonce = get(correctNonceState);
const tx = await generateTransaction({
senderKey: account.stxPrivateKey,
nonce,
txData: pendingTransaction,
});
return tx;
},
dangerouslyAllowMutability: true,
});
export const postConditionsStore = atom<PostCondition[]>({
key: 'transaction.postConditions',
default: [],
});
export const postConditionsHasSetStore = atom<boolean>({
key: 'transaction.postConditions.hasSet',
export const isUnauthorizedTransactionState = atom<boolean>({
key: 'transaction.is-unauthorized-tx',
default: false,
});
export const currentPostConditionIndexStore = atom<number | undefined>({
key: 'transaction.currentPostConditionIndex',
default: undefined,
});
export const currentPostConditionStore = selector<PostCondition | undefined>({
key: 'transaction.currentPostCondition',
get: ({ get }) => {
const index = get(currentPostConditionIndexStore);
if (index === undefined) {
return undefined;
}
const postConditions = get(postConditionsStore);
return postConditions[index];
},
});
export const transactionBroadcastErrorStore = atom<string | null>({
export const transactionBroadcastErrorState = atom<string | null>({
key: 'transaction.broadcast-error',
default: null,
});
@@ -232,11 +44,11 @@ export const internalTransactionStore = selectorFamily({
async ({ get }) => {
try {
const asset = get(selectedAssetStore);
const currentAccount = get(currentAccountStore);
const currentAccountStxAddress = get(currentAccountStxAddressStore);
const currentAccount = get(currentAccountState);
const currentAccountStxAddress = get(currentAccountStxAddressState);
if (!asset || !currentAccount || !currentAccountStxAddress) return null;
const network = get(stacksNetworkStore);
const balances = get(accountBalancesStore);
const balances = get(accountBalancesState);
if (asset.type === 'stx') {
const mStx = stxToMicroStx(amount);
try {
@@ -294,8 +106,3 @@ export const internalTransactionStore = selectorFamily({
}
},
});
export const isUnauthorizedTransactionStore = atom<boolean>({
key: 'transaction.is-unauthorized-tx',
default: false,
});

View File

@@ -0,0 +1,82 @@
import { selector, waitForAll } from 'recoil';
import { requestTokenPayloadState } from '@store/transactions/requests';
import { smartContractClientState } from '@store/common/api-clients';
import { TransactionTypes } from '@stacks/connect';
import { ContractInterfaceResponse } from '@stacks/blockchain-api-client';
import { ContractInterfaceFunction } from '@stacks/rpc-client';
type ContractInterfaceResponseWithFunctions = Omit<ContractInterfaceResponse, 'functions'> & {
functions: ContractInterfaceFunction[];
};
export const transactionContractInterfaceState = selector<
undefined | ContractInterfaceResponseWithFunctions
>({
key: 'transactions.contract-interface',
get: async ({ get }) => {
const { payload, client } = get(
waitForAll({
payload: requestTokenPayloadState,
client: smartContractClientState,
})
);
if (payload?.txType !== TransactionTypes.ContractCall) return;
try {
const data = await client.getContractInterface({
contractName: payload.contractName,
contractAddress: payload.contractAddress,
});
if (!data) return undefined;
return data as ContractInterfaceResponseWithFunctions;
} catch (e) {
return undefined;
}
},
});
export const transactionContractSourceState = selector({
key: 'transactions.contract-source',
get: async ({ get }) => {
const { payload, client } = get(
waitForAll({
payload: requestTokenPayloadState,
client: smartContractClientState,
})
);
if (payload?.txType !== TransactionTypes.ContractCall) return;
try {
return client.getContractSource({
contractName: payload.contractName,
contractAddress: payload.contractAddress,
});
} catch (e) {
return undefined;
}
},
});
export const transactionFunctionsState = selector({
key: 'transactions.pending-transaction-function',
get: ({ get }) => {
const { payload, contractInterface } = get(
waitForAll({
payload: requestTokenPayloadState,
contractInterface: transactionContractInterfaceState,
})
);
if (!payload || payload.txType !== 'contract_call' || !contractInterface) return undefined;
const selectedFunction = contractInterface.functions.find(func => {
return func.name === payload.functionName;
});
if (!selectedFunction) {
throw new Error(
`Attempting to call a function (\`${payload.functionName}\`) that ` +
`does not exist on contract ${payload.contractAddress}.${payload.contractName}`
);
}
return selectedFunction;
},
});

View File

@@ -1,9 +1,9 @@
import { selector } from 'recoil';
import { selector, waitForAll } from 'recoil';
import { selectedAssetStore } from '@store/assets/asset-search';
import {
accountBalancesStore,
currentAccountStore,
currentAccountStxAddressStore,
accountBalancesState,
currentAccountState,
currentAccountStxAddressState,
} from '@store/accounts';
import { stacksNetworkStore } from '@store/networks';
import { correctNonceState } from '@store/accounts/nonce';
@@ -11,12 +11,17 @@ import { correctNonceState } from '@store/accounts/nonce';
export const makeFungibleTokenTransferState = selector({
key: 'transaction.internal-transaction-asset',
get: ({ get }) => {
const asset = get(selectedAssetStore);
const currentAccount = get(currentAccountStore);
const network = get(stacksNetworkStore);
const balances = get(accountBalancesStore);
const stxAddress = get(currentAccountStxAddressStore);
const nonce = get(correctNonceState);
const { asset, currentAccount, network, balances, stxAddress, nonce } = get(
waitForAll({
asset: selectedAssetStore,
currentAccount: currentAccountState,
network: stacksNetworkStore,
balances: accountBalancesState,
stxAddress: currentAccountStxAddressState,
nonce: correctNonceState,
})
);
if (asset && currentAccount && stxAddress) {
const { contractName, contractAddress, name: assetName } = asset;
return {
@@ -30,10 +35,7 @@ export const makeFungibleTokenTransferState = selector({
contractAddress,
contractName,
};
} else {
console.error('[makeFungibleTokenTransferState]: malformed state');
}
return;
},
});

View File

@@ -0,0 +1,70 @@
import { selector, waitForAll } from 'recoil';
import { ChainID } from '@stacks/transactions';
import { StacksMainnet, StacksTestnet } from '@stacks/network';
import { currentNetworkState } from '@store/networks';
import { correctNonceState } from '@store/accounts/nonce';
import { currentAccountState, currentAccountStxAddressState } from '@store/accounts';
import { requestTokenPayloadState } from '@store/transactions/requests';
import { getPostCondition, handlePostConditions } from '@common/post-condition-utils';
import { generateTransaction } from '@common/transaction-utils';
export const postConditionsState = selector({
key: 'transactions.post-conditions',
get: ({ get }) => {
const { payload, address } = get(
waitForAll({
payload: requestTokenPayloadState,
address: currentAccountStxAddressState,
})
);
if (!payload || !address) return;
if (payload.postConditions) {
if (payload.stxAddress)
return handlePostConditions(payload.postConditions, payload.stxAddress, address);
return payload.postConditions.map(getPostCondition);
}
return [];
},
});
export const pendingTransactionState = selector({
key: 'transactions.pending',
get: ({ get }) => {
const { payload, postConditions, _network } = get(
waitForAll({
payload: requestTokenPayloadState,
postConditions: postConditionsState,
_network: currentNetworkState,
})
);
const network =
_network.chainId === ChainID.Mainnet ? new StacksMainnet() : new StacksTestnet();
network.coreApiUrl = _network.url;
if (!payload) return;
return { ...payload, postConditions, network };
},
});
export const signedTransactionState = selector({
key: 'transactions.signed',
get: async ({ get }) => {
const { account, pendingTransaction, nonce } = get(
waitForAll({
account: currentAccountState,
pendingTransaction: pendingTransactionState,
nonce: correctNonceState,
})
);
if (!account || !pendingTransaction) return;
return generateTransaction({
senderKey: account.stxPrivateKey,
nonce,
txData: pendingTransaction,
});
},
});

View File

@@ -0,0 +1,39 @@
import { atom, selector } from 'recoil';
import { getPayloadFromToken } from '@store/transactions/utils';
export const requestTokenState = atom<string | null>({
key: 'transactions.request-token',
default: null,
effects_UNSTABLE: [
({ setSelf, trigger }) => {
if (trigger === 'get') {
const requestToken = window.location.href?.split('?request=')[1];
if (requestToken) {
setSelf(requestToken);
}
}
return () => {
setSelf(null);
};
},
],
});
export const requestTokenPayloadState = selector({
key: 'transactions.request-token-payload',
get: ({ get }) => {
const token = get(requestTokenState);
return token ? getPayloadFromToken(token) : null;
},
});
export const transactionRequestStxAddressState = selector({
key: 'transactions.request.address',
get: ({ get }) => get(requestTokenPayloadState)?.stxAddress,
});
export const transactionRequestNetwork = selector({
key: 'transactions.request.network',
get: ({ get }) => get(requestTokenPayloadState)?.network,
});

View File

@@ -0,0 +1,28 @@
import { decodeToken } from 'jsontokens';
import {
ContractCallPayload,
ContractDeployPayload,
STXTransferPayload,
TransactionTypes,
} from '@stacks/connect';
import { TransactionPayloadWithAttachment } from '@store/transaction';
export function getPayloadFromToken(requestToken: string) {
if (!requestToken) return undefined;
const token = decodeToken(requestToken);
const payload = token.payload as unknown as TransactionPayloadWithAttachment;
if (payload.txType === TransactionTypes.ContractCall)
return payload as ContractCallPayload & {
attachment?: string;
};
if (payload.txType === TransactionTypes.ContractDeploy)
return payload as ContractDeployPayload & {
attachment?: string;
};
if (payload.txType === TransactionTypes.STXTransfer)
return payload as STXTransferPayload & {
attachment?: string;
};
return payload;
}

View File

@@ -8,7 +8,7 @@ import {
} from '@stacks/wallet-sdk';
import { gaiaUrl } from '@common/constants';
export const secretKeyState = atom<string | undefined>({
export const secretKeyState = atom<Uint8Array | undefined>({
key: 'wallet.secret-key',
default: undefined,
});
@@ -39,9 +39,7 @@ export const walletConfigStore = selector<WalletConfig | null>({
key: 'wallet.wallet-config',
get: async ({ get }) => {
const wallet = get(walletState);
if (!wallet) {
return null;
}
if (!wallet) return null;
const gaiaHubConfig = await createWalletGaiaConfig({ wallet, gaiaHubUrl: gaiaUrl });
return fetchWalletConfig({ wallet, gaiaHubConfig });
},