@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
406 lines (359 loc) • 11.8 kB
text/typescript
import { BigNumber } from "bignumber.js";
import { useEffect, useReducer, useCallback, useRef } from "react";
import { log } from "@ledgerhq/logs";
import { getAccountBridge } from ".";
import { getMainAccount } from "../account";
import { delay } from "../promise";
import type { Account, AccountBridge, AccountLike } from "@ledgerhq/types-live";
import type { Transaction, TransactionStatus } from "../generated/types";
import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
import { LiveConfig } from "@ledgerhq/live-config/LiveConfig";
export type State<T extends Transaction = Transaction> = {
account: AccountLike | null | undefined;
parentAccount: Account | null | undefined;
transaction: T | null | undefined;
status: TransactionStatus;
statusOnTransaction: T | null | undefined;
errorAccount: Error | null | undefined;
errorStatus: Error | null | undefined;
syncing: boolean;
synced: boolean;
};
export type Result<T extends Transaction = Transaction> = {
transaction: T | null | undefined;
setTransaction: (arg0: T) => void;
updateTransaction: (updater: (arg0: T) => T) => void;
account: AccountLike | null | undefined;
parentAccount: Account | null | undefined;
setAccount: (arg0: AccountLike, arg1: Account | null | undefined) => void;
status: TransactionStatus;
bridgeError: Error | null | undefined;
bridgePending: boolean;
};
type Actions<T extends Transaction = Transaction> =
| {
type: "setAccount";
account: AccountLike;
parentAccount: Account | null | undefined;
}
| {
type: "setTransaction";
transaction: T;
}
| {
type: "updateTransaction";
updater: (transaction: T) => T;
}
| {
type: "onStatus";
status: TransactionStatus;
transaction: T;
}
| {
type: "onStatusError";
error: Error;
}
| {
type: "setTransaction";
transaction: T;
}
| {
type: "onStartSync";
}
| {
type: "onSync";
};
type Reducer<T extends Transaction = Transaction> = (
state: State<T>,
action: Actions<T>,
) => State<T>;
const initial: State<Transaction> = {
account: null,
parentAccount: null,
transaction: null,
status: {
errors: {},
warnings: {},
estimatedFees: new BigNumber(0),
amount: new BigNumber(0),
totalSpent: new BigNumber(0),
},
statusOnTransaction: null,
errorAccount: null,
errorStatus: null,
syncing: false,
synced: false,
};
export const shouldSyncBeforeTx = (currency: CryptoCurrency): boolean => {
const currencyConfig = LiveConfig.getValueByKey(`config_currency_${currency.id}`);
const sharedConfig = LiveConfig.getValueByKey("config_currency");
if (currencyConfig && "syncBeforeTx" in currencyConfig) {
return currencyConfig.syncBeforeTx === true;
} else {
return sharedConfig && "syncBeforeTx" in sharedConfig && sharedConfig.syncBeforeTx === true;
}
};
const makeInit =
<T extends Transaction = Transaction>(
optionalInit: (() => Partial<State<T>>) | null | undefined,
) =>
(): State<T> => {
let s = initial as State<T>;
if (optionalInit) {
const patch = optionalInit();
const { account, parentAccount, transaction } = patch;
if (account) {
s = (reducer as Reducer<T>)(s, {
type: "setAccount",
account,
parentAccount,
});
}
if (transaction) {
s = (reducer as Reducer<T>)(s, {
type: "setTransaction",
transaction,
});
}
}
return s;
};
const reducer = <T extends Transaction = Transaction>(
state: State<T>,
action: Actions<T>,
): State<T> => {
switch (action.type) {
case "setAccount": {
const { account, parentAccount } = action;
try {
const mainAccount = getMainAccount(account, parentAccount);
const bridge = getAccountBridge(account, parentAccount) as AccountBridge<T>;
const subAccountId = account.type !== "Account" && account.id;
let t = bridge.createTransaction(mainAccount);
if (
// @ts-expect-error transaction.mode is not available on all union types. type guard is required
state.transaction?.mode &&
// @ts-expect-error transaction.mode is not available on all union types. type guard is required
state.transaction.mode !== t.mode
) {
t = bridge.updateTransaction(t, {
// @ts-expect-error transaction.mode is not available on all union types. type guard is required
mode: state.transaction.mode,
});
}
if (subAccountId) {
t = { ...t, subAccountId };
}
return {
...initial,
account,
parentAccount,
transaction: t,
syncing: false,
synced: false,
} as State<T>;
} catch (e: any) {
return {
...initial,
account,
parentAccount,
errorAccount: e,
syncing: false,
synced: false,
} as State<T>;
}
}
case "setTransaction":
if (state.transaction === action.transaction) return state;
return { ...state, transaction: action.transaction };
case "updateTransaction": {
if (!state.transaction) return state;
const transaction = action.updater(state.transaction);
if (state.transaction === transaction) return state;
return { ...state, transaction };
}
case "onStatus":
return {
...state,
errorStatus: null,
transaction: action.transaction,
status: action.status,
statusOnTransaction: action.transaction,
};
case "onStatusError":
if (action.error === state.errorStatus) return state;
return { ...state, errorStatus: action.error };
case "onStartSync":
return { ...state, syncing: true, synced: false };
case "onSync":
return { ...state, syncing: false, synced: true };
default:
return state;
}
};
const INITIAL_ERROR_RETRY_DELAY = 1000;
const ERROR_RETRY_DELAY_MULTIPLIER = 1.5;
const DEBOUNCE_STATUS_DELAY = 300;
const useBridgeTransaction = <T extends Transaction = Transaction>(
optionalInit?: (() => Partial<State<T>>) | null | undefined,
): Result<T> => {
const [
{
account,
parentAccount,
transaction,
status,
statusOnTransaction,
syncing,
synced,
errorAccount,
errorStatus,
},
dispatch,
] = useReducer(reducer as Reducer<T>, undefined, makeInit<T>(optionalInit));
const setAccount = useCallback(
(account, parentAccount) =>
dispatch({
type: "setAccount",
account,
parentAccount,
}),
[dispatch],
);
const setTransaction = useCallback(
transaction =>
dispatch({
type: "setTransaction",
transaction,
}),
[dispatch],
);
const updateTransaction = useCallback(
updater =>
dispatch({
type: "updateTransaction",
updater,
}),
[dispatch],
);
const mainAccount = account ? getMainAccount(account, parentAccount) : null;
const errorDelay = useRef(INITIAL_ERROR_RETRY_DELAY);
const statusIsPending = useRef(false); // Stores if status already being processed
const shouldSync = mainAccount && shouldSyncBeforeTx(mainAccount.currency);
useEffect(() => {
if (mainAccount === null || synced || syncing) return;
if (!shouldSync) return; // skip sync if not required by currency config
dispatch({ type: "onStartSync" });
const bridge = getAccountBridge(mainAccount, null);
const sub = bridge.sync(mainAccount, { paginationConfig: {} }).subscribe({
error: (_: Error) => {
// we do not block the user in case of error for now but it should be the case
dispatch({ type: "onSync" });
},
complete: () => {
dispatch({ type: "onSync" });
},
});
return () => {
sub.unsubscribe();
};
}, [mainAccount, synced, syncing, shouldSync]);
const bridgePending = transaction !== statusOnTransaction;
// when transaction changes, prepare the transaction
useEffect(() => {
let ignore = false;
let errorTimeout: NodeJS.Timeout | null;
// If bridge is not pending, transaction change is due to
// the last onStatus dispatch (prepareTransaction changed original transaction) and must be ignored
if (!bridgePending && !synced) return;
if (mainAccount && transaction) {
// We don't debounce first status refresh, but any subsequent to avoid multiple calls
// First call is immediate
const debounce = statusIsPending.current ? delay(DEBOUNCE_STATUS_DELAY) : null;
statusIsPending.current = true; // consider pending until status is resolved (error or success)
Promise.resolve(debounce)
.then(() => getAccountBridge(mainAccount, null))
.then(async bridge => {
if (ignore) return;
const preparedTransaction = await bridge.prepareTransaction(mainAccount, transaction);
if (ignore) return;
const status = await bridge.getTransactionStatus(mainAccount, preparedTransaction);
if (ignore) return;
return {
preparedTransaction,
status,
};
})
.then(
result => {
if (ignore || !result) return;
const { preparedTransaction, status } = result;
errorDelay.current = INITIAL_ERROR_RETRY_DELAY; // reset delay
statusIsPending.current = false; // status is now synced with transaction
dispatch({
type: "onStatus",
status,
transaction: preparedTransaction,
});
},
e => {
if (ignore) return;
statusIsPending.current = false;
dispatch({
type: "onStatusError",
error: e,
});
log("useBridgeTransaction", "prepareTransaction failed " + String(e));
// After X seconds of hanging in this error case, we try again
log("useBridgeTransaction", "retrying prepareTransaction...");
errorTimeout = setTimeout(() => {
// $FlowFixMe (mobile)
errorDelay.current *= ERROR_RETRY_DELAY_MULTIPLIER; // increase delay
// $FlowFixMe
const transactionCopy = {
...transaction,
};
dispatch({
type: "setTransaction",
transaction: transactionCopy,
}); // $FlowFixMe (mobile)
}, errorDelay.current);
},
);
}
return () => {
ignore = true;
if (errorTimeout) {
clearTimeout(errorTimeout);
errorTimeout = null;
}
};
}, [transaction, mainAccount, bridgePending, dispatch, synced]);
const bridgeError = errorAccount || errorStatus;
useEffect(() => {
if (bridgeError && globalOnBridgeError) {
globalOnBridgeError(bridgeError);
}
}, [bridgeError]);
return {
transaction,
setTransaction,
updateTransaction,
status,
account,
parentAccount,
setAccount,
bridgeError,
bridgePending: bridgePending && (shouldSync ? !synced : true),
};
};
type GlobalBridgeErrorFn = null | ((error: any) => void);
let globalOnBridgeError: GlobalBridgeErrorFn = null;
// allows to globally set a bridge error catch function in order to log it / report to sentry / ...
export function setGlobalOnBridgeError(f: GlobalBridgeErrorFn): void {
globalOnBridgeError = f;
}
export function getGlobalOnBridgeError(): GlobalBridgeErrorFn {
return globalOnBridgeError;
}
export default useBridgeTransaction;