Skip to content

Commit c301a47

Browse files
authored
Update send form to account for asset decimals (#194)
* Update send form to account for asset decimals * Render major value correctly in confirmation modal * Remove unused strings * PR feedback; submit transaction value as string
1 parent 08127a8 commit c301a47

File tree

4 files changed

+155
-76
lines changed

4 files changed

+155
-76
lines changed

main/api/transactions/handleSendTransaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const handleSendTransactionInput = z.object({
1111
fromAccount: z.string(),
1212
toAccount: z.string(),
1313
assetId: z.string(),
14-
amount: z.number(),
14+
amount: z.string(),
1515
fee: z.number(),
1616
memo: z.string().optional(),
1717
});

renderer/components/SendAssetsForm/ConfirmTransactionModal/ConfirmTransactionModal.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import { defineMessages, useIntl } from "react-intl";
1717
import { trpcReact } from "@/providers/TRPCProvider";
1818
import { COLORS } from "@/ui/colors";
1919
import { PillButton } from "@/ui/PillButton/PillButton";
20+
import { CurrencyUtils } from "@/utils/currency";
2021
import { formatOre } from "@/utils/ironUtils";
2122

22-
import { TransactionData } from "../transactionSchema";
23+
import { TransactionData, AssetOptionType } from "../transactionSchema";
2324

2425
const messages = defineMessages({
2526
confirmTransactionDetails: {
@@ -78,19 +79,22 @@ const messages = defineMessages({
7879
cancel: {
7980
defaultMessage: "Cancel",
8081
},
82+
unknownAsset: {
83+
defaultMessage: "unknown asset",
84+
},
8185
});
8286

8387
type Props = {
8488
isOpen: boolean;
85-
transactionData: TransactionData | null;
86-
selectedAssetName: string;
89+
transactionData: TransactionData;
90+
selectedAsset?: AssetOptionType;
8791
onCancel: () => void;
8892
};
8993

9094
export function ConfirmTransactionModal({
9195
isOpen,
9296
transactionData,
93-
selectedAssetName,
97+
selectedAsset,
9498
onCancel,
9599
}: Props) {
96100
const {
@@ -112,9 +116,6 @@ export function ConfirmTransactionModal({
112116
}, [onCancel, reset]);
113117

114118
const handleSubmit = useCallback(() => {
115-
if (!transactionData) {
116-
throw new Error("No transaction data");
117-
}
118119
sendTransaction(transactionData);
119120
}, [sendTransaction, transactionData]);
120121

@@ -149,8 +150,13 @@ export function ConfirmTransactionModal({
149150
{formatMessage(messages.amount)}
150151
</Text>
151152
<Text fontSize="md">
152-
{formatOre(transactionData?.amount ?? 0)}{" "}
153-
{selectedAssetName}
153+
{CurrencyUtils.render(
154+
transactionData.amount.toString(),
155+
transactionData.assetId,
156+
selectedAsset?.asset.verification,
157+
)}{" "}
158+
{selectedAsset?.assetName ??
159+
formatMessage(messages.unknownAsset)}
154160
</Text>
155161
</Box>
156162

renderer/components/SendAssetsForm/SendAssetsForm.tsx

Lines changed: 116 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import { RenderError } from "@/ui/Forms/FormField/FormField";
1111
import { Select } from "@/ui/Forms/Select/Select";
1212
import { TextInput } from "@/ui/Forms/TextInput/TextInput";
1313
import { PillButton } from "@/ui/PillButton/PillButton";
14+
import { CurrencyUtils } from "@/utils/currency";
1415
import { hexToUTF16String } from "@/utils/hexToUTF16String";
15-
import { formatOre, parseIron } from "@/utils/ironUtils";
16+
import { formatOre } from "@/utils/ironUtils";
1617
import { asQueryString } from "@/utils/parseRouteQuery";
1718
import { sliceToUtf8Bytes } from "@/utils/sliceToUtf8Bytes";
1819
import { truncateString } from "@/utils/truncateString";
@@ -23,6 +24,9 @@ import {
2324
TransactionData,
2425
TransactionFormData,
2526
transactionSchema,
27+
AccountType,
28+
BalanceType,
29+
AssetOptionType,
2630
} from "./transactionSchema";
2731
import {
2832
AccountSyncingMessage,
@@ -64,17 +68,11 @@ const messages = defineMessages({
6468
fastFeeLabel: {
6569
defaultMessage: "Fast",
6670
},
67-
unknownAsset: {
68-
defaultMessage: "unknown asset",
69-
},
7071
estimatedFeeDefaultError: {
7172
defaultMessage: "An error occurred while estimating the transaction fee",
7273
},
7374
});
7475

75-
type AccountType = TRPCRouterOutputs["getAccounts"][number];
76-
type BalanceType = AccountType["balances"]["iron"];
77-
7876
function getAccountBalances(account: AccountType): {
7977
[key: string]: BalanceType;
8078
} {
@@ -134,7 +132,7 @@ export function SendAssetsFormContent({
134132
} = useForm<TransactionFormData>({
135133
resolver: zodResolver(transactionSchema),
136134
defaultValues: {
137-
amount: 0,
135+
amount: "0",
138136
fromAccount: defaultAccount,
139137
toAccount: defaultToAddress ?? "",
140138
assetId: defaultAssetId,
@@ -143,26 +141,97 @@ export function SendAssetsFormContent({
143141
});
144142

145143
const fromAccountValue = watch("fromAccount");
146-
const assetValue = watch("assetId");
144+
const assetIdValue = watch("assetId");
147145
const feeValue = watch("fee");
148146
const amountValue = watch("amount");
149147
const toAccountValue = watch("toAccount");
150148

149+
useEffect(() => {
150+
// If the 'assetId' changes, reset the 'amount' field
151+
// to prevent issues if there is a mismatch in decimals
152+
// between two assets.
153+
const _unused = assetIdValue;
154+
resetField("amount");
155+
}, [resetField, assetIdValue]);
156+
157+
const selectedAccount = useMemo(() => {
158+
const match = accountsData?.find(
159+
(account) => account.name === fromAccountValue,
160+
);
161+
if (!match) {
162+
return accountsData[0];
163+
}
164+
return match;
165+
}, [accountsData, fromAccountValue]);
166+
167+
const accountBalances = useMemo(() => {
168+
return getAccountBalances(selectedAccount);
169+
}, [selectedAccount]);
170+
171+
const assetOptionsMap = useMemo(() => {
172+
const entries: Array<[string, AssetOptionType]> = Object.values(
173+
accountBalances,
174+
).map((balance) => {
175+
const assetName = hexToUTF16String(balance.asset.name);
176+
const confirmed = CurrencyUtils.render(
177+
BigInt(balance.confirmed),
178+
balance.asset.id,
179+
balance.asset.verification,
180+
);
181+
return [
182+
balance.asset.id,
183+
{
184+
assetName: assetName,
185+
label: assetName + ` (${confirmed})`,
186+
value: balance.asset.id,
187+
asset: balance.asset,
188+
},
189+
];
190+
});
191+
return new Map(entries);
192+
}, [accountBalances]);
193+
194+
const assetOptions = useMemo(
195+
() => Array.from(assetOptionsMap.values()),
196+
[assetOptionsMap],
197+
);
198+
199+
const assetAmountToSend = useMemo(() => {
200+
const assetToSend = assetOptionsMap.get(assetIdValue);
201+
if (!assetToSend) {
202+
return 0n;
203+
}
204+
205+
const [amountToSend, conversionError] = CurrencyUtils.tryMajorToMinor(
206+
amountValue.toString(),
207+
assetIdValue,
208+
assetToSend.asset.verification,
209+
);
210+
211+
if (conversionError) {
212+
return 0n;
213+
}
214+
215+
return amountToSend;
216+
}, [amountValue, assetIdValue, assetOptionsMap]);
217+
151218
const { data: estimatedFeesData, error: estimatedFeesError } =
152219
trpcReact.getEstimatedFees.useQuery(
153220
{
154221
accountName: fromAccountValue,
155222
output: {
156-
amount: parseIron(amountValue),
157-
assetId: assetValue,
223+
amount: Number(assetAmountToSend),
224+
assetId: assetIdValue,
158225
memo: "",
159-
publicAddress: toAccountValue,
226+
// For fee estimation, the actual address of the recipient is not important, is just has to be
227+
// a valid address. Therefore, we're just going to use the address of the first account.
228+
publicAddress: accountsData[0].address,
160229
},
161230
},
162231
{
163232
retry: false,
164233
enabled:
165-
amountValue > 0 &&
234+
Number(amountValue) > 0 &&
166235
!errors.memo &&
167236
!errors.amount &&
168237
!errors.toAccount &&
@@ -191,37 +260,12 @@ export function SendAssetsFormContent({
191260
return null;
192261
}, [isAccountSynced, nodeStatusData?.blockchain.synced]);
193262

194-
const selectedAccount = useMemo(() => {
195-
const match = accountsData?.find(
196-
(account) => account.name === fromAccountValue,
197-
);
198-
if (!match) {
199-
return accountsData[0];
200-
}
201-
return match;
202-
}, [accountsData, fromAccountValue]);
203-
204-
const accountBalances = useMemo(() => {
205-
return getAccountBalances(selectedAccount);
206-
}, [selectedAccount]);
207-
208-
const assetOptions = useMemo(() => {
209-
return Object.values(accountBalances).map((balance) => {
210-
const assetName = hexToUTF16String(balance.asset.name);
211-
return {
212-
assetName: assetName,
213-
label: assetName + ` (${formatOre(balance.confirmed)})`,
214-
value: balance.asset.id,
215-
};
216-
});
217-
}, [accountBalances]);
218-
219263
// Resets asset field to $IRON if a newly selected account does not have the selected asset
220264
useEffect(() => {
221-
if (!Object.hasOwn(accountBalances, assetValue)) {
265+
if (!Object.hasOwn(accountBalances, assetIdValue)) {
222266
resetField("assetId");
223267
}
224-
}, [assetValue, resetField, selectedAccount, accountBalances]);
268+
}, [assetIdValue, resetField, selectedAccount, accountBalances]);
225269

226270
const { data: contactsData } = trpcReact.getContacts.useQuery();
227271
const formattedContacts = useMemo(() => {
@@ -242,9 +286,8 @@ export function SendAssetsFormContent({
242286
const currentBalance = Number(
243287
accountBalances[data.assetId].confirmed,
244288
);
245-
const amountAsIron = parseIron(data.amount);
246289

247-
if (currentBalance < amountAsIron) {
290+
if (currentBalance < assetAmountToSend) {
248291
setError("amount", {
249292
type: "custom",
250293
message: formatMessage(messages.insufficientFundsError),
@@ -262,11 +305,12 @@ export function SendAssetsFormContent({
262305
}
263306

264307
const fee = estimatedFeesData[data.fee];
308+
265309
setPendingTransaction({
266310
fromAccount: data.fromAccount,
267311
toAccount: data.toAccount,
268312
assetId: data.assetId,
269-
amount: parseIron(data.amount),
313+
amount: assetAmountToSend.toString(),
270314
fee: fee,
271315
memo: data.memo ?? "",
272316
});
@@ -292,7 +336,7 @@ export function SendAssetsFormContent({
292336

293337
<Select
294338
{...register("assetId")}
295-
value={assetValue}
339+
value={assetIdValue}
296340
label={formatMessage(messages.assetLabel)}
297341
options={assetOptions}
298342
error={errors.assetId?.message}
@@ -317,27 +361,37 @@ export function SendAssetsFormContent({
317361
return;
318362
}
319363

364+
const assetToSend = assetOptionsMap.get(assetIdValue);
365+
const decimals =
366+
assetToSend?.asset.verification?.decimals ?? 0;
367+
320368
let finalValue = azValue;
321369

322-
// only allow up to 8 decimal places
323-
const parts = azValue.split(".");
324-
if (parts[1]?.length > 8) {
325-
finalValue = `${parts[0]}.${parts[1].slice(0, 8)}`;
370+
if (decimals === 0) {
371+
// If decimals is 0, take the left side of the decimal.
372+
// If no decimal is present, this will still work correctly.
373+
finalValue = azValue.split(".")[0];
374+
} else {
375+
// Otherwise, take the left side of the decimal and up to the correct number of decimal places.
376+
const parts = azValue.split(".");
377+
if (parts[1]?.length > decimals) {
378+
finalValue = `${parts[0]}.${parts[1].slice(0, decimals)}`;
379+
}
326380
}
327381

328382
field.onChange(finalValue);
329383
}}
330384
onFocus={() => {
331-
if (field.value === 0) {
385+
if (field.value === "0") {
332386
field.onChange("");
333387
}
334388
}}
335389
onBlur={() => {
336390
if (!field.value) {
337-
field.onChange(0);
391+
field.onChange("0");
338392
}
339-
if (String(field.value).endsWith(".")) {
340-
field.onChange(String(field.value).slice(0, -1));
393+
if (field.value.endsWith(".")) {
394+
field.onChange(field.value.slice(0, -1));
341395
}
342396
}}
343397
label={formatMessage(messages.amountLabel)}
@@ -417,17 +471,16 @@ export function SendAssetsFormContent({
417471
</PillButton>
418472
</HStack>
419473
</chakra.form>
420-
<ConfirmTransactionModal
421-
isOpen={!!pendingTransaction}
422-
transactionData={pendingTransaction}
423-
selectedAssetName={
424-
assetOptions.find(({ value }) => value === assetValue)?.assetName ??
425-
formatMessage(messages.unknownAsset)
426-
}
427-
onCancel={() => {
428-
setPendingTransaction(null);
429-
}}
430-
/>
474+
{pendingTransaction && (
475+
<ConfirmTransactionModal
476+
isOpen
477+
transactionData={pendingTransaction}
478+
selectedAsset={assetOptionsMap.get(assetIdValue)}
479+
onCancel={() => {
480+
setPendingTransaction(null);
481+
}}
482+
/>
483+
)}
431484
</>
432485
);
433486
}

0 commit comments

Comments
 (0)