Merge pull request #196 from XRPLF/feat/fee-hint

Fee hints in transactions.
This commit is contained in:
muzamil
2022-06-01 16:21:24 +05:30
committed by GitHub
6 changed files with 230 additions and 90 deletions

View File

@@ -14,6 +14,8 @@ import Button from "../Button";
import Flex from "../Flex"; import Flex from "../Flex";
import { TxJson } from "./json"; import { TxJson } from "./json";
import { TxUI } from "./ui"; import { TxUI } from "./ui";
import { default as _estimateFee } from "../../utils/estimateFee";
import toast from 'react-hot-toast';
export interface TransactionProps { export interface TransactionProps {
header: string; header: string;
@@ -76,13 +78,19 @@ const Transaction: FC<TransactionProps> = ({
} else { } else {
setState({ txIsDisabled: false }); setState({ txIsDisabled: false });
} }
}, [selectedAccount?.value, selectedTransaction?.value, setState, txIsLoading]); }, [
selectedAccount?.value,
selectedTransaction?.value,
setState,
txIsLoading,
]);
const submitTest = useCallback(async () => { const submitTest = useCallback(async () => {
let st: TransactionState | undefined; let st: TransactionState | undefined;
const tt = txState.selectedTransaction?.value;
if (viewType === "json") { if (viewType === "json") {
// save the editor state first // save the editor state first
const pst = prepareState(editorValue || '', txState); const pst = prepareState(editorValue || "", tt);
if (!pst) return; if (!pst) return;
st = setState(pst); st = setState(pst);
@@ -102,7 +110,7 @@ const Transaction: FC<TransactionProps> = ({
const options = prepareOptions(st); const options = prepareOptions(st);
if (options.Destination === null) { if (options.Destination === null) {
throw Error("Destination account cannot be null") throw Error("Destination account cannot be null");
} }
await sendTransaction(account, options, { logPrefix }); await sendTransaction(account, options, { logPrefix });
@@ -116,7 +124,17 @@ const Transaction: FC<TransactionProps> = ({
} }
} }
setState({ txIsLoading: false }); setState({ txIsLoading: false });
}, [viewType, accounts, txIsDisabled, setState, header, editorValue, txState, selectedAccount?.value, prepareOptions]); }, [
viewType,
accounts,
txIsDisabled,
setState,
header,
editorValue,
txState,
selectedAccount?.value,
prepareOptions,
]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
modifyTransaction(header, { viewType }, { replaceState: true }); modifyTransaction(header, { viewType }, { replaceState: true });
@@ -129,6 +147,31 @@ const Transaction: FC<TransactionProps> = ({
[editorSavedValue, editorSettings.tabSize, prepareOptions] [editorSavedValue, editorSettings.tabSize, prepareOptions]
); );
const estimateFee = useCallback(
async (st?: TransactionState, opts?: { silent?: boolean }) => {
const state = st || txState;
const ptx = prepareOptions(state);
const account = accounts.find(
acc => acc.address === state.selectedAccount?.value
);
if (!account) {
if (!opts?.silent) {
toast.error("Please select account from the list.")
}
return
};
ptx.Account = account.address;
ptx.Sequence = account.sequence;
const res = await _estimateFee(ptx, account, opts);
const fee = res?.base_fee;
setState({ estimatedFee: fee });
return fee;
},
[accounts, prepareOptions, setState, txState]
);
return ( return (
<Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}> <Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}>
{viewType === "json" ? ( {viewType === "json" ? (
@@ -137,9 +180,10 @@ const Transaction: FC<TransactionProps> = ({
header={header} header={header}
state={txState} state={txState}
setState={setState} setState={setState}
estimateFee={estimateFee}
/> />
) : ( ) : (
<TxUI state={txState} setState={setState} /> <TxUI state={txState} setState={setState} estimateFee={estimateFee} />
)} )}
<Flex <Flex
row row

View File

@@ -29,6 +29,7 @@ interface JsonProps {
header?: string; header?: string;
setState: (pTx?: Partial<TransactionState> | undefined) => void; setState: (pTx?: Partial<TransactionState> | undefined) => void;
state: TransactionState; state: TransactionState;
estimateFee?: () => Promise<string | undefined>;
} }
export const TxJson: FC<JsonProps> = ({ export const TxJson: FC<JsonProps> = ({
@@ -38,22 +39,37 @@ export const TxJson: FC<JsonProps> = ({
setState, setState,
}) => { }) => {
const { editorSettings, accounts } = useSnapshot(state); const { editorSettings, accounts } = useSnapshot(state);
const { editorValue = value, selectedTransaction } = txState; const { editorValue = value, estimatedFee } = txState;
const { theme } = useTheme(); const { theme } = useTheme();
const [hasUnsaved, setHasUnsaved] = useState(false); const [hasUnsaved, setHasUnsaved] = useState(false);
const [currTxType, setCurrTxType] = useState<string | undefined>(
txState.selectedTransaction?.value
);
useEffect(() => { useEffect(() => {
setState({ editorValue: value }); setState({ editorValue: value });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); }, [value]);
useEffect(() => {
const parsed = parseJSON(editorValue);
if (!parsed) return;
const tt = parsed.TransactionType;
const tx = transactionsData.find(t => t.TransactionType === tt);
if (tx) setCurrTxType(tx.TransactionType);
else {
setCurrTxType(undefined);
}
}, [editorValue]);
useEffect(() => { useEffect(() => {
if (editorValue === value) setHasUnsaved(false); if (editorValue === value) setHasUnsaved(false);
else setHasUnsaved(true); else setHasUnsaved(true);
}, [editorValue, value]); }, [editorValue, value]);
const saveState = (value: string, txState: TransactionState) => { const saveState = (value: string, transactionType?: string) => {
const tx = prepareState(value, txState); const tx = prepareState(value, transactionType);
if (tx) setState(tx); if (tx) setState(tx);
}; };
@@ -68,7 +84,7 @@ export const TxJson: FC<JsonProps> = ({
const onExit = (value: string) => { const onExit = (value: string) => {
const options = parseJSON(value); const options = parseJSON(value);
if (options) { if (options) {
saveState(value, txState); saveState(value, currTxType);
return; return;
} }
showAlert("Error!", { showAlert("Error!", {
@@ -82,9 +98,10 @@ export const TxJson: FC<JsonProps> = ({
const path = `file:///${header}`; const path = `file:///${header}`;
const monaco = useMonaco(); const monaco = useMonaco();
const getSchemas = useCallback((): any[] => { const getSchemas = useCallback(async (): Promise<any[]> => {
const tt = selectedTransaction?.value; const txObj = transactionsData.find(
const txObj = transactionsData.find(td => td.TransactionType === tt); td => td.TransactionType === currTxType
);
let genericSchemaProps: any; let genericSchemaProps: any;
if (txObj) { if (txObj) {
@@ -98,7 +115,6 @@ export const TxJson: FC<JsonProps> = ({
{} {}
); );
} }
return [ return [
{ {
uri: "file:///main-schema.json", // id of the first schema uri: "file:///main-schema.json", // id of the first schema
@@ -130,6 +146,9 @@ export const TxJson: FC<JsonProps> = ({
Amount: { Amount: {
$ref: "file:///amount-schema.json", $ref: "file:///amount-schema.json",
}, },
Fee: {
$ref: "file:///fee-schema.json",
},
}, },
}, },
}, },
@@ -141,17 +160,30 @@ export const TxJson: FC<JsonProps> = ({
enum: accounts.map(acc => acc.address), enum: accounts.map(acc => acc.address),
}, },
}, },
{
uri: "file:///fee-schema.json",
schema: {
type: "string",
title: "Fee type",
const: estimatedFee,
description: estimatedFee
? "Above mentioned value is recommended base fee"
: undefined,
},
},
{ {
...amountSchema, ...amountSchema,
}, },
]; ];
}, [accounts, header, selectedTransaction?.value]); }, [accounts, currTxType, estimatedFee, header]);
useEffect(() => { useEffect(() => {
if (!monaco) return; if (!monaco) return;
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ getSchemas().then(schemas => {
validate: true, monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
schemas: getSchemas(), validate: true,
schemas,
});
}); });
}, [getSchemas, monaco]); }, [getSchemas, monaco]);
@@ -184,19 +216,13 @@ export const TxJson: FC<JsonProps> = ({
// register onExit cb // register onExit cb
const model = editor.getModel(); const model = editor.getModel();
model?.onWillDispose(() => onExit(model.getValue())); model?.onWillDispose(() => onExit(model.getValue()));
// set json defaults
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: getSchemas(),
});
}} }}
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
/> />
{hasUnsaved && ( {hasUnsaved && (
<Text muted small css={{ position: "absolute", bottom: 0, right: 0 }}> <Text muted small css={{ position: "absolute", bottom: 0, right: 0 }}>
This file has unsaved changes.{" "} This file has unsaved changes.{" "}
<Link onClick={() => saveState(editorValue, txState)}>save</Link>{" "} <Link onClick={() => saveState(editorValue, currTxType)}>save</Link>{" "}
<Link onClick={discardChanges}>discard</Link> <Link onClick={discardChanges}>discard</Link>
</Text> </Text>
)} )}

View File

@@ -1,4 +1,4 @@
import { FC } from "react"; import { FC, useCallback, useState } from "react";
import Container from "../Container"; import Container from "../Container";
import Flex from "../Flex"; import Flex from "../Flex";
import Input from "../Input"; import Input from "../Input";
@@ -9,17 +9,26 @@ import {
TransactionState, TransactionState,
transactionsData, transactionsData,
TxFields, TxFields,
getTxFields,
} from "../../state/transactions"; } from "../../state/transactions";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import state from "../../state"; import state from "../../state";
import { streamState } from "../DebugStream"; import { streamState } from "../DebugStream";
import { Button } from "..";
interface UIProps { interface UIProps {
setState: (pTx?: Partial<TransactionState> | undefined) => void; setState: (
pTx?: Partial<TransactionState> | undefined
) => TransactionState | undefined;
state: TransactionState; state: TransactionState;
estimateFee?: (...arg: any) => Promise<string | undefined>;
} }
export const TxUI: FC<UIProps> = ({ state: txState, setState }) => { export const TxUI: FC<UIProps> = ({
state: txState,
setState,
estimateFee,
}) => {
const { accounts } = useSnapshot(state); const { accounts } = useSnapshot(state);
const { const {
selectedAccount, selectedAccount,
@@ -45,32 +54,54 @@ export const TxUI: FC<UIProps> = ({ state: txState, setState }) => {
})) }))
.filter(acc => acc.value !== selectedAccount?.value); .filter(acc => acc.value !== selectedAccount?.value);
const resetOptions = (tt: string) => { const [feeLoading, setFeeLoading] = useState(false);
const txFields: TxFields | undefined = transactionsData.find(
tx => tx.TransactionType === tt
);
if (!txFields) return setState({ txFields: {} }); const resetOptions = useCallback(
(tt: string) => {
const _txFields = Object.keys(txFields) const fields = getTxFields(tt);
.filter(key => !["TransactionType", "Account", "Sequence"].includes(key)) if (!fields.Destination) setState({ selectedDestAccount: null });
.reduce<TxFields>( return setState({ txFields: fields });
(tf, key) => ((tf[key as keyof TxFields] = (txFields as any)[key]), tf), },
{} [setState]
); );
if (!_txFields.Destination) setState({ selectedDestAccount: null });
setState({ txFields: _txFields });
};
const handleSetAccount = (acc: SelectOption) => { const handleSetAccount = (acc: SelectOption) => {
setState({ selectedAccount: acc }); setState({ selectedAccount: acc });
streamState.selectedAccount = acc; streamState.selectedAccount = acc;
}; };
const handleSetField = useCallback(
(field: keyof TxFields, value: string, opFields?: TxFields) => {
const fields = opFields || txFields;
const obj = fields[field];
setState({
txFields: {
...fields,
[field]: typeof obj === "object" ? { ...obj, $value: value } : value,
},
});
},
[setState, txFields]
);
const handleEstimateFee = useCallback(
async (state?: TransactionState, silent?: boolean) => {
setFeeLoading(true);
const fee = await estimateFee?.(state, { silent });
if (fee) handleSetField("Fee", fee, state?.txFields);
setFeeLoading(false);
},
[estimateFee, handleSetField]
);
const handleChangeTxType = (tt: SelectOption) => { const handleChangeTxType = (tt: SelectOption) => {
setState({ selectedTransaction: tt }); setState({ selectedTransaction: tt });
resetOptions(tt.value);
const newState = resetOptions(tt.value);
handleEstimateFee(newState, true);
}; };
const specialFields = ["TransactionType", "Account", "Destination"]; const specialFields = ["TransactionType", "Account", "Destination"];
@@ -87,7 +118,7 @@ export const TxUI: FC<UIProps> = ({ state: txState, setState }) => {
height: "calc(100% - 45px)", height: "calc(100% - 45px)",
}} }}
> >
<Flex column fluid css={{ height: "100%", overflowY: "auto" }}> <Flex column fluid css={{ height: "100%", overflowY: "auto", pr: "$1" }}>
<Flex <Flex
row row
fluid fluid
@@ -174,36 +205,49 @@ export const TxUI: FC<UIProps> = ({ state: txState, setState }) => {
} }
let isXrp = typeof _value === "object" && _value.$type === "xrp"; let isXrp = typeof _value === "object" && _value.$type === "xrp";
const isFee = field === "Fee";
return ( return (
<Flex <Flex column key={field} css={{ mb: "$2", pr: "1px" }}>
key={field} <Flex
row row
fluid fluid
css={{ css={{
justifyContent: "flex-end", justifyContent: "flex-end",
alignItems: "center", alignItems: "center",
mb: "$3", position: "relative",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
{field + (isXrp ? " (XRP)" : "")}:{" "}
</Text>
<Input
value={value}
onChange={e => {
setState({
txFields: {
...txFields,
[field]:
typeof _value === "object"
? { ..._value, $value: e.target.value }
: e.target.value,
},
});
}} }}
css={{ width: "70%", flex: "inherit" }} >
/> <Text muted css={{ mr: "$3" }}>
{field + (isXrp ? " (XRP)" : "")}:{" "}
</Text>
<Input
value={value}
onChange={e => {
handleSetField(field, e.target.value);
}}
css={{ width: "70%", flex: "inherit" }}
/>
{isFee && (
<Button
size="xs"
variant="primary"
outline
isLoading={feeLoading}
css={{
position: "absolute",
right: "$2",
fontSize: "$xs",
cursor: "pointer",
alignContent: "center",
display: "flex",
}}
onClick={() => handleEstimateFee()}
>
Suggest
</Button>
)}
</Flex>
</Flex> </Flex>
); );
})} })}

View File

@@ -20,8 +20,8 @@ export const sendTransaction = async (account: IAccount, txOptions: TransactionO
const { Fee = "1000", ...opts } = txOptions const { Fee = "1000", ...opts } = txOptions
const tx: TransactionOptions = { const tx: TransactionOptions = {
Account: account.address, Account: account.address,
Sequence: account.sequence, // TODO auto-fillable Sequence: account.sequence,
Fee, // TODO auto-fillable Fee, // TODO auto-fillable default
...opts ...opts
}; };

View File

@@ -19,7 +19,8 @@ export interface TransactionState {
txFields: TxFields; txFields: TxFields;
viewType: 'json' | 'ui', viewType: 'json' | 'ui',
editorSavedValue: null | string, editorSavedValue: null | string,
editorValue?: string editorValue?: string,
estimatedFee?: string
} }
@@ -93,7 +94,7 @@ export const modifyTransaction = (
Object.keys(partialTx).forEach(k => { Object.keys(partialTx).forEach(k => {
// Typescript mess here, but is definetly safe! // Typescript mess here, but is definetly safe!
const s = tx.state as any; const s = tx.state as any;
const p = partialTx as any; const p = partialTx as any; // ? Make copy
if (!deepEqual(s[k], p[k])) s[k] = p[k]; if (!deepEqual(s[k], p[k])) s[k] = p[k];
}); });
@@ -140,7 +141,7 @@ export const prepareTransaction = (data: any) => {
} }
// editor value to state // editor value to state
export const prepareState = (value: string, txState: TransactionState) => { export const prepareState = (value: string, transactionType?: string) => {
const options = parseJSON(value); const options = parseJSON(value);
if (!options) { if (!options) {
showAlert("Error!", { showAlert("Error!", {
@@ -151,7 +152,7 @@ export const prepareState = (value: string, txState: TransactionState) => {
const { Account, TransactionType, Destination, ...rest } = options; const { Account, TransactionType, Destination, ...rest } = options;
let tx: Partial<TransactionState> = {}; let tx: Partial<TransactionState> = {};
const { txFields } = txState const txFields = getTxFields(transactionType)
if (Account) { if (Account) {
const acc = state.accounts.find(acc => acc.address === Account); const acc = state.accounts.find(acc => acc.address === Account);
@@ -206,7 +207,7 @@ export const prepareState = (value: string, txState: TransactionState) => {
if (isXrp) { if (isXrp) {
rest[field] = { rest[field] = {
$type: "xrp", $type: "xrp",
$value: +value / 1000000, // TODO maybe use bigint? $value: +value / 1000000, // ! maybe use bigint?
}; };
} else if (typeof value === "object") { } else if (typeof value === "object") {
rest[field] = { rest[field] = {
@@ -222,4 +223,24 @@ export const prepareState = (value: string, txState: TransactionState) => {
return tx return tx
} }
export const getTxFields = (tt?: string) => {
const txFields: TxFields | undefined = transactionsData.find(
tx => tx.TransactionType === tt
);
if (!txFields) return {}
let _txFields = Object.keys(txFields)
.filter(
key => !["TransactionType", "Account", "Sequence"].includes(key)
)
.reduce<TxFields>(
(tf, key) => (
(tf[key as keyof TxFields] = (txFields as any)[key]), tf
),
{}
);
return _txFields
}
export { transactionsData } export { transactionsData }

View File

@@ -1,24 +1,29 @@
import toast from 'react-hot-toast';
import { derive, sign } from "xrpl-accountlib" import { derive, sign } from "xrpl-accountlib"
import state, { IAccount } from "../state" import state, { IAccount } from "../state"
const estimateFee = async (tx: Record<string, unknown>, account: IAccount): Promise<null | { base_fee: string, median_fee: string; minimum_fee: string; open_ledger_fee: string; }> => { const estimateFee = async (tx: Record<string, unknown>, account: IAccount, opts: { silent?: boolean } = {}): Promise<null | { base_fee: string, median_fee: string; minimum_fee: string; open_ledger_fee: string; }> => {
const copyTx = JSON.parse(JSON.stringify(tx))
delete copyTx['SigningPubKey']
if (!copyTx.Fee) {
copyTx.Fee = '1000'
}
const keypair = derive.familySeed(account.secret)
const { signedTransaction } = sign(copyTx, keypair);
try { try {
const copyTx = JSON.parse(JSON.stringify(tx))
delete copyTx['SigningPubKey']
if (!copyTx.Fee) {
copyTx.Fee = '1000'
}
const keypair = derive.familySeed(account.secret)
const { signedTransaction } = sign(copyTx, keypair);
const res = await state.client?.send({ command: 'fee', tx_blob: signedTransaction }) const res = await state.client?.send({ command: 'fee', tx_blob: signedTransaction })
if (res && res.drops) { if (res && res.drops) {
return res.drops; return res.drops;
} }
return null return null
} catch (err) { } catch (err) {
console.log(err) if (!opts.silent) {
console.error(err)
toast.error("Cannot estimate fee.") // ? Some better msg
}
return null return null
} }
} }