Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,12 @@ import { NftCard as Card, DataTableCell } from "@components/StyledComponents";
import { convertCborValue } from "@utils/cbor";
import { shortenSubstateId, displayNftId } from "@utils/helpers";
import SendNft from "./SendNft";

import { Fragment } from "react/jsx-runtime";

function NftCard({ nft }: { nft: NonFungibleToken }) {
const mutableData = convertCborValue(nft.mutable_data);
const data = convertCborValue(nft.data);
const data = convertCborValue(nft.data) as Record<string, any> | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard against non-object NFT data before rendering.

convertCborValue(nft.data) can return primitives/arrays; passing truthy non-objects into NftData will render character-by-character rows. Narrow to plain objects first.

Apply this diff:

-  const data = convertCborValue(nft.data) as Record<string, any> | undefined;
+  const rawData = convertCborValue(nft.data);
+  const data =
+    rawData && typeof rawData === "object" && !Array.isArray(rawData)
+      ? (rawData as Record<string, unknown>)
+      : undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = convertCborValue(nft.data) as Record<string, any> | undefined;
const rawData = convertCborValue(nft.data);
const data =
rawData && typeof rawData === "object" && !Array.isArray(rawData)
? (rawData as Record<string, unknown>)
: undefined;
🤖 Prompt for AI Agents
In
applications/tari_walletd/web_ui/src/routes/AssetVault/NFTs/components/NftParts.tsx
around line 36, convertCborValue(nft.data) can return primitives or arrays which
then render incorrectly; instead of blindly casting, first check the result is a
plain object (data !== null && typeof data === 'object' && !Array.isArray(data))
and only then assign/cast it to Record<string, any>; otherwise set data to
undefined (or skip rendering NftData) so NftData receives only plain objects.

const imageUrl = mutableData?.image_url;
const originalOwner = data?.original_owner;

return (
<Grid item xs={12} sm={6} md={4} lg={3}>
Expand All @@ -59,19 +58,15 @@ function NftCard({ nft }: { nft: NonFungibleToken }) {
<Typography variant="h6" component="h2" fontWeight="bold" noWrap>
{displayNftId(nft.nft_id)}
</Typography>
<Chip
icon={
nft.is_burnt ? (
<CancelRoundedIcon style={{ height: 16, width: 16 }} />
) : (
<CheckCircleRoundedIcon style={{ height: 16, width: 16 }} />
)
}
label={nft.is_burnt ? "Burnt" : "Active"}
color={nft.is_burnt ? "error" : "success"}
size="small"
variant="outlined"
/>
{nft.is_burnt && (
<Chip
icon={<CancelRoundedIcon style={{ height: 16, width: 16 }} />}
label={"Burnt"}
color={"error"}
size="small"
variant="outlined"
/>
)}
</Box>

<Divider />
Expand All @@ -81,10 +76,7 @@ function NftCard({ nft }: { nft: NonFungibleToken }) {
</Typography>

<Divider />
<Typography variant="subtitle2">Original Owner:</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
<CopyAddress address={originalOwner || ""} />
</Typography>
{data ? <NftData data={data} /> : null}

<SendNft nftId={nft.nft_id} resourceAddress={nft.resource_address} />
</CardContent>
Expand All @@ -93,6 +85,24 @@ function NftCard({ nft }: { nft: NonFungibleToken }) {
);
}

function NftData({ data }: { data: Record<string, any> }) {
return (
<>
{Object.keys(data).map((key, i) => {
const value = data[key];
return (
<Fragment key={i}>
<Typography variant="subtitle2">{key}</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
<CopyAddress address={String(value)} />
</Typography>
</Fragment>
);
})}
</>
);
}

function NftRow({ nft }: { nft: NonFungibleToken }) {
const mutableData = convertCborValue(nft.mutable_data);
const data = convertCborValue(nft.data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,19 @@ function Tokens({ account }: { account: Account }) {

return (
<>
<SendMoneyDialog
open={resourceToSend !== null}
handleClose={() => setResourceToSend(null)}
onSendComplete={() => setResourceToSend(null)}
resource_address={resourceToSend?.address}
resource_type={resourceToSend?.resource_type!}
token_symbol={
balancesData?.balances.find((b: BalanceEntry) => b.resource_address === resourceToSend?.address)
?.token_symbol || ""
}
/>
{resourceToSend == null ? null : (
<SendMoneyDialog
open={true}
handleClose={() => setResourceToSend(null)}
onSendComplete={() => setResourceToSend(null)}
resource_address={resourceToSend?.address}
resource_type={resourceToSend?.resource_type!}
token_symbol={
balancesData?.balances.find((b: BalanceEntry) => b.resource_address === resourceToSend?.address)
?.token_symbol || ""
}
/>
)}
<FetchStatusCheck
isError={balancesIsError as boolean}
errorMessage={(balancesError as { message?: string })?.message || "Error fetching data"}
Expand Down Expand Up @@ -202,37 +204,34 @@ function Tokens({ account }: { account: Account }) {
</TableRow>
</TableHead>
<TableBody>
{balancesData?.balances.map(
(
{
resource_address,
balance,
resource_type,
confidential_balance,
token_symbol,
vault_address,
divisibility,
}: BalanceEntry,
i: number,
) => (
<BalanceRow
key={i}
token_symbol={token_symbol || ""}
resource_address={resource_address}
resource_type={resource_type}
balance={balance}
confidential_balance={confidential_balance}
vault_address={vault_address ?? undefined} // convert null to undefined
divisibility={divisibility}
onSendClicked={
handleSendResourceClicked as (
resource_address: ResourceAddress,
resource_type: ResourceType,
) => void
}
/>
),
)}
{balancesData?.balances
.filter((b) => BigInt(b.balance) > 0n || BigInt(b.confidential_balance) > 0n)
.map(
(
{
resource_address,
balance,
resource_type,
confidential_balance,
token_symbol,
vault_address,
divisibility,
}: BalanceEntry,
i: number,
) => (
<BalanceRow
key={i}
token_symbol={token_symbol || ""}
resource_address={resource_address}
resource_type={resource_type}
balance={balance}
confidential_balance={confidential_balance}
vault_address={vault_address ?? undefined} // convert null to undefined
divisibility={divisibility}
onSendClicked={handleSendResourceClicked}
/>
),
)}
</TableBody>
</Table>
</TableContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,12 @@ export function SendMoneyDialog(props: SendMoneyDialogProps) {
.map((b: BalanceEntry) => b.resource_address) as string[];

// Find the available balance for the resource we're trying to send
const balanceEntry = data?.balances?.find(
(b: BalanceEntry) => b.resource_address === (props.resource_address || XTR),
);
const balanceEntry = data?.balances?.find((b: BalanceEntry) => b.resource_address === props.resource_address);

if (!balanceEntry) {
console.warn("No balance entry found for resource", props.resource_address);
return null;
}

// Function to calculate available balance based on input selection
const calculateAvailableBalance = () => {
Expand Down Expand Up @@ -115,17 +118,6 @@ export function SendMoneyDialog(props: SendMoneyDialogProps) {

const availableBalance = calculateAvailableBalance();

const transfer = {
account: substateIdToString(account.component_address),
amount: Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry?.divisibility || 6)),
resource_address: props.resource_address!,
destination_address: transferFormState.address,
resourceType: props.resource_type,
output_to_revealed: !transferFormState.outputToConfidential,
input_selection: transferFormState.inputSelection as ConfidentialTransferInputSelection,
badge: transferFormState.badge,
};

function setFormValue(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;

Expand Down Expand Up @@ -179,14 +171,19 @@ export function SendMoneyDialog(props: SendMoneyDialogProps) {
if (!account || isEstimatingFee || !transferFormState.address.trim() || !transferFormState.amount) {
return;
}
if (!balanceEntry) {
console.warn("No balance entry found for resource", props.resource_address);
return;
}

setIsEstimatingFee(true);

try {
let amount = Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry.divisibility));
// Create transfer object with current form state
const currentTransfer = {
account: substateIdToString(account.component_address),
amount: Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry?.divisibility || 6)),
amount,
resource_address: props.resource_address || XTR,
Comment on lines +182 to 187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Avoid precision loss; compute atomic amount via BigInt; remove XTR fallback

Using Number with Math.pow can lose precision for high divisibility tokens. Convert the decimal string to atomic units using BigInt and pass as a string (if API supports it). Also, the XTR fallback is unnecessary/inconsistent given the earlier guard.

-      let amount = Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry.divisibility));
+      const amount = decimalToAtomic(transferFormState.amount, balanceEntry.divisibility);
       // Create transfer object with current form state
       const currentTransfer = {
         account: substateIdToString(account.component_address),
-        amount,
-        resource_address: props.resource_address || XTR,
+        amount, // string to avoid precision loss
+        resource_address: props.resource_address!,

Helper to add (outside this hunk), e.g. near the top of the file:

function decimalToAtomic(amountStr: string, divisibility: number): string {
  const sanitized = (amountStr || "").trim();
  if (!sanitized) return "0";
  const [wholeRaw, fracRaw = ""] = sanitized.split(".");
  const whole = wholeRaw.replace(/\D/g, "") || "0";
  const fracClean = fracRaw.replace(/\D/g, "");
  const frac = fracClean.slice(0, Math.max(0, divisibility)).padEnd(Math.max(0, divisibility), "0");
  const base = BigInt(10) ** BigInt(Math.max(0, divisibility));
  const atomic = BigInt(whole || "0") * base + BigInt(frac || "0");
  return atomic.toString();
}

To verify the request type accepts a string amount, run:


🏁 Script executed:

#!/bin/bash
# Locate the accounts transfer hook/type and its request shape
rg -nC3 'useAccountsTransfer\(|accountsTransfer\(|Transfer' --type=ts --type=tsx
rg -nC3 '\bamount\b' --type=ts --type=tsx

Length of output: 55


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for SendMoney.tsx..."
files=$(find . -type f -name 'SendMoney.tsx' 2>/dev/null || true)
if [ -z "$files" ]; then
  echo "SendMoney.tsx not found via find"
else
  for f in $files; do
    echo "== $f =="
    sed -n '1,360p' "$f"
  done
fi

echo
echo "--- Searching for transfer-related symbols and amount/resource_address usages ---"
rg -n --hidden -S 'useAccountsTransfer' || true
rg -n --hidden -S 'accountsTransfer' || true
rg -n --hidden -S '\btransfer\b' || true
rg -n --hidden -S 'resource_address' || true
rg -n --hidden -S '\bamount\b' || true
rg -n --hidden -S '\bXTR\b' || true

Length of output: 270100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== bindings/src/types/wallet-daemon-client/AccountsTransferRequest.ts =="
sed -n '1,240p' bindings/src/types/wallet-daemon-client/AccountsTransferRequest.ts || true
echo
echo "== applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts =="
sed -n '1,420p' applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts || true
echo
echo "== clients/javascript/wallet_daemon_client/src/index.ts (accountsTransfer) =="
sed -n '1,420p' clients/javascript/wallet_daemon_client/src/index.ts || true
echo
echo "== bindings/src/helpers/consts.ts =="
sed -n '1,240p' bindings/src/helpers/consts.ts || true

Length of output: 23867


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for Amount type in bindings =="
rg -n --hidden -S '(^|\s)(export )?(type|interface) Amount\b' bindings || true

echo
echo "== bindings/src/types/Amount.ts =="
sed -n '1,240p' bindings/src/types/Amount.ts || true

echo
echo "== show generated bindings index to confirm path imports =="
sed -n '1,240p' bindings/src/index.ts || true

Length of output: 7836


Convert decimal input to atomic units with BigInt, send as string, and remove XTR fallback (update hook types)

  • Rationale: Number + Math.pow loses precision for high-divisibility tokens; convert user decimal string -> atomic units with BigInt (decimalToAtomic) and send that atomic value as a string (bindings allow string amounts: bindings/src/types/Amount.ts -> export type Amount = string | number).
  • Change SendMoney.tsx: replace Math.floor((parseFloat(... ) || 0) * Math.pow(10, balanceEntry.divisibility)) in both estimateFee and handleConfirm with decimalToAtomic(transferFormState.amount, balanceEntry.divisibility). Add the decimalToAtomic helper (returns string) and pass amount as that string. Remove the resource_address fallback (use props.resource_address! instead of props.resource_address || XTR). Location: applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/components/SendMoney.tsx (estimateFee + handleConfirm).
  • Change hook typing: update TransferParams.amount in applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts from number -> Amount (imported from @tari-project/typescript-bindings) or at least string | number so passing a string does not break TypeScript; confirm and update any other callers that rely on TransferParams.
🤖 Prompt for AI Agents
In
applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/components/SendMoney.tsx
around lines 182-187 (and the matching spots in estimateFee and handleConfirm),
replace the Math.floor(parseFloat(...) * Math.pow(10, ...)) logic with a
decimalToAtomic helper that converts the decimal string and divisibility into
atomic units as a string, pass that string as the transfer amount, and remove
the XTR fallback by using props.resource_address! instead of
props.resource_address || XTR; additionally add the decimalToAtomic helper
(returns string) to this file or a shared util and ensure both estimateFee and
handleConfirm use it. Update the TransferParams.amount type in
applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts to use
Amount (imported from @tari-project/typescript-bindings) or at minimum string |
number so the string atomic amount is accepted, and update any callers of
TransferParams to comply with the new type.

destination_address: transferFormState.address,
resourceType: props.resource_type,
Expand Down Expand Up @@ -246,6 +243,18 @@ export function SendMoneyDialog(props: SendMoneyDialogProps) {
setActiveStep(2);

try {
let amount = Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry.divisibility));
const transfer = {
account: substateIdToString(account.component_address),
amount,
resource_address: props.resource_address!,
destination_address: transferFormState.address,
resourceType: props.resource_type,
output_to_revealed: !transferFormState.outputToConfidential,
input_selection: transferFormState.inputSelection as ConfidentialTransferInputSelection,
badge: transferFormState.badge,
};

Comment on lines +246 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Apply the same BigInt amount conversion in confirm path

Mirror the safe atomic amount conversion in the submit path for consistency.

-      let amount = Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry.divisibility));
-      const transfer = {
-        account: substateIdToString(account.component_address),
-        amount,
+      const amount = decimalToAtomic(transferFormState.amount, balanceEntry.divisibility);
+      const transfer = {
+        account: substateIdToString(account.component_address),
+        amount, // string
         resource_address: props.resource_address!,
         destination_address: transferFormState.address,
         resourceType: props.resource_type,
         output_to_revealed: !transferFormState.outputToConfidential,
         input_selection: transferFormState.inputSelection as ConfidentialTransferInputSelection,
         badge: transferFormState.badge,
       };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let amount = Math.floor((parseFloat(transferFormState.amount) || 0) * Math.pow(10, balanceEntry.divisibility));
const transfer = {
account: substateIdToString(account.component_address),
amount,
resource_address: props.resource_address!,
destination_address: transferFormState.address,
resourceType: props.resource_type,
output_to_revealed: !transferFormState.outputToConfidential,
input_selection: transferFormState.inputSelection as ConfidentialTransferInputSelection,
badge: transferFormState.badge,
};
const amount = decimalToAtomic(transferFormState.amount, balanceEntry.divisibility);
const transfer = {
account: substateIdToString(account.component_address),
amount, // string
resource_address: props.resource_address!,
destination_address: transferFormState.address,
resourceType: props.resource_type,
output_to_revealed: !transferFormState.outputToConfidential,
input_selection: transferFormState.inputSelection as ConfidentialTransferInputSelection,
badge: transferFormState.badge,
};
🤖 Prompt for AI Agents
In
applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/components/SendMoney.tsx
around lines 246 to 257, the confirm path constructs transfer.amount as a Number
using Math.floor which is inconsistent with the submit path; change the atomic
amount computation to produce a BigInt the same way the submit path does by
parsing transferFormState.amount, multiplying by 10**balanceEntry.divisibility,
flooring, and wrapping the result with BigInt (e.g. const amount =
BigInt(Math.floor((parseFloat(...) || 0) * Math.pow(10, divisibility)))); then
assign that BigInt to transfer.amount and ensure types align with the transfer
object.

await sendIt?.({
...transfer,
dry_run: false,
Expand Down Expand Up @@ -299,7 +308,7 @@ export function SendMoneyDialog(props: SendMoneyDialogProps) {
isEstimatingFee={isEstimatingFee}
availableBalance={availableBalance}
token_symbol={props.token_symbol}
divisibility={balanceEntry?.divisibility || 6}
divisibility={balanceEntry.divisibility}
onSubmit={handleFormSubmit}
onCancel={handleClose}
onFormValueChange={setFormValue}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,8 @@ export default function FormStep({
}

// Otherwise, show formatted value
const hasDecimals = amount.includes(".") && amount.split(".")[1].length > 0;
return num.toLocaleString("en-US", {
minimumFractionDigits: hasDecimals ? 0 : 2,
minimumFractionDigits: 0,
maximumFractionDigits: divisibility,
});
};
Expand Down Expand Up @@ -205,13 +204,13 @@ export default function FormStep({
error={hasInsufficientFunds}
helperText={
hasInsufficientFunds
? `Insufficient funds. Available balance: ${formatDisplayCurrency(availableBalance || 0)}`
? `Insufficient funds. Available balance: ${formatDisplayCurrency(availableBalance || 0, divisibility, token_symbol)}`
: availableBalance !== undefined
? `Available balance: ${formatDisplayCurrency(availableBalance)}`
? `Available balance: ${formatDisplayCurrency(availableBalance, divisibility, token_symbol)}`
: undefined
}
InputProps={{
placeholder: "0.0",
placeholder: "0" + (divisibility > 0 ? "." + "0".repeat(divisibility) : ""),
endAdornment: token_symbol ? <InputAdornment position="end">{token_symbol}</InputAdornment> : undefined,
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ function StealthUtxoList({ account }: { account: Account }) {
{shortenString(utxo.address.id)}
<CopyToClipboard copy={utxo.address.id} />
</DataTableCell>
<DataTableCell>{bigintToDecimalString(utxo.value, 6)} {currencySymbol}</DataTableCell>
<DataTableCell>
{bigintToDecimalString(utxo.value, 6)} {currencySymbol}
</DataTableCell>
<DataTableCell>
<StatusChip status={utxo.status} />
</DataTableCell>
Expand Down
12 changes: 7 additions & 5 deletions applications/tari_walletd/web_ui/src/utils/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,18 @@ export const formatCurrency = (amount: number | bigint): string => {
}
};

// Helper function for formatting amounts that are already in display units (XTR)
export const formatDisplayCurrency = (amount: number): string => {
const currencySymbol = useCurrencyStore.getState().currencySymbol;

// Helper function for formatting currency amounts
export const formatDisplayCurrency = (
amount: number,
divisibility: number,
currencySymbol: string | undefined,
): string => {
if (isNaN(amount)) {
return `0 ${currencySymbol}`;
}
return `${amount.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: CURRENCY.DECIMALS,
maximumFractionDigits: divisibility,
})} ${currencySymbol}`;
};

Expand Down
1 change: 1 addition & 0 deletions crates/wallet/storage_sqlite/src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ impl WalletStoreReader for ReadTransaction<'_> {
}

let rows = query
.order_by(stealth_outputs::id.desc())
.get_results::<(models::StealthOutput, String)>(self.connection())
.map_err(|e| WalletStorageError::general(OPERATION, e))?;

Expand Down
Loading