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
50 changes: 40 additions & 10 deletions applications/tari_walletd/src/handlers/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,22 +978,52 @@ pub async fn handle_stealth_transfer(
));
};

let outputs = req
.transfers
.into_iter()
.map(|transfer| match transfer.destination_address.pay_ref() {
Some(pay_ref) => {
let memo = transfer.output_memo.unwrap_or_else(|| Memo::new_message("").unwrap());
if memo.as_pay_ref().is_some() {
warn!(
target: LOG_TARGET,
"❗️ Overwriting existing pay ref in memo for transfer to address {}",
transfer.destination_address
);
}

// Try to add the pay ref to the memo
let memo_bytes = memo
.as_memo_message()
.map(|s| s.as_bytes())
.or_else(|| memo.as_memo_bytes())
.ok_or_else(|| invalid_params("pay ref", Some("can only include pay ref in message memo")))?;
let memo = Memo::new_pay_ref_and_bytes_truncate(pay_ref, memo_bytes)
.expect("payref + truncated message fits in memo");
Comment on lines +1001 to +1002

@coderabbitai coderabbitai Bot Nov 4, 2025

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 | 🔴 Critical

Handle potential None from new_pay_ref_and_bytes_truncate gracefully.

The expect() on line 1002 will panic if new_pay_ref_and_bytes_truncate returns None. According to the implementation in memo.rs, this function returns None when the pay_ref itself is too large to fit in a memo (exceeds MAX_BYTES_LENGTH - 1). This should be handled with a proper error instead of panicking.

Apply this diff to handle the error gracefully:

-                let memo = Memo::new_pay_ref_and_bytes_truncate(pay_ref, memo_bytes)
-                    .expect("payref + truncated message fits in memo");
+                let memo = Memo::new_pay_ref_and_bytes_truncate(pay_ref, memo_bytes)
+                    .ok_or_else(|| invalid_params("pay_ref", Some("pay_ref is too large to fit in memo")))?;
📝 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 memo = Memo::new_pay_ref_and_bytes_truncate(pay_ref, memo_bytes)
.expect("payref + truncated message fits in memo");
let memo = Memo::new_pay_ref_and_bytes_truncate(pay_ref, memo_bytes)
.ok_or_else(|| invalid_params("pay_ref", Some("pay_ref is too large to fit in memo")))?;
🤖 Prompt for AI Agents
In applications/tari_walletd/src/handlers/accounts.rs around lines 1001-1002,
replace the unconditional expect on Memo::new_pay_ref_and_bytes_truncate (which
can return None when pay_ref is too large) with a graceful check that handles
the None case and returns an appropriate error instead of panicking; detect the
None result, construct and return a meaningful error (e.g., a
BadRequest/InvalidArgument or domain-specific error indicating the pay_ref is
too large to fit in a memo), or propagate it via the function's Result, ensuring
no panic path remains.

@sdbondi sdbondi Nov 4, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Because payref has maximum of 64 bytes and the rest of truncated, returning None is not possible (or if it does, it's a bug)

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the implementation of new_pay_ref_and_bytes_truncate in memo.rs
echo "=== Checking new_pay_ref_and_bytes_truncate implementation ==="
ast-grep --pattern $'fn new_pay_ref_and_bytes_truncate($$$) -> Option<Self> {
  $$$
}'

echo -e "\n=== Checking MAX_BYTES_LENGTH constant in memo.rs ==="
rg -n "MAX_BYTES_LENGTH" crates/wallet/crypto/src/memo.rs -A 2 -B 2

echo -e "\n=== Checking PayRef::MAX_LEN ==="
rg -n "MAX_LEN" crates/ootle_address/src/pay_ref.rs -A 1 -B 1

Length of output: 4948


@sdbondi You're absolutely correct! I've verified the implementation and your reasoning is sound:

  • PayRef::MAX_LEN = 64 bytes (enforced in PayRef::new_checked)
  • Memo::MAX_BYTES_LENGTH = 253 bytes
  • new_pay_ref_and_bytes_truncate only returns None if pay_ref.len() > MAX_BYTES_LENGTH - 1 (i.e., > 252 bytes)
  • Since 64 < 252, a valid PayRef will never cause this function to return None

The expect() is safe here because the payref is validated during address decoding, ensuring it never exceeds 64 bytes. The message portion is truncated as needed to fit in the remaining space (253 - 1 byte length prefix - payref length).

The only minor note is that the expect message "payref + truncated message fits in memo" is slightly imprecise—it's really about the payref size being valid, but that's a trivial naming detail. The logic is correct.


🧬 Code Graph Analysis Results

[relevant_code_snippets]

  • File: crates/wallet/crypto/src/memo.rs
    Lines: 81-91
    Content:

    pub fn new_pay_ref_and_bytes_truncate<P: AsRef<[u8]>, B: AsRef<[u8]>>(pay_ref: P, msg_bytes: B) -> Option<Self> {
        let pr = pay_ref.as_ref();
        let available_len = (Self::MAX_BYTES_LENGTH - 1).checked_sub(pr.len())?; // -1 for length prefix byte
        let mb = msg_bytes.as_ref();
        let mb = if mb.len() > available_len {
            &mb[..available_len]
        } else {
            mb
        };
        Self::new_pay_ref_and_bytes(pay_ref, mb)
    }
    
  • File: bindings/src/types/wallet-daemon-client/TransferOutput.ts
    Lines: 6-24
    Content:

    export type TransferOutput = {
      /**
       * Destination address used to derive the UTXO encryption keys, owner signature and the account in which to
       * deposit revealed funds
       */
      address: OotleAddress;
      /**
       * Amount to spend to a revealed output
       */
      revealed_amount: Amount;
      /**
       * Amount to spend to a blinded output
       */
      blinded_amount: Amount;
      /**
       * Optional memo to include a memo in the output. This memo is encrypted and can only be read by the recipient.
       */
      memo: Memo | null;
    };
    
  • File: bindings/src/types/wallet-daemon-client/Memo.ts
    Lines: 3-3
    Content:

    export type Memo = { U256: string } | { Message: string } | { Bytes: string } | { PayRefAndBytes: string };
    

✏️ Learnings added
Learnt from: sdbondi
Repo: tari-project/tari-ootle PR: 1629
File: applications/tari_walletd/src/handlers/accounts.rs:1001-1002
Timestamp: 2025-11-04T10:10:24.249Z
Learning: In applications/tari_walletd/src/handlers/accounts.rs, the expect() on Memo::new_pay_ref_and_bytes_truncate at line 1002 is safe and intentional. PayRef is validated to be at most 64 bytes during address decoding (PayRef::MAX_LEN = 64), and the function only returns None if payref exceeds 252 bytes (Memo::MAX_BYTES_LENGTH - 1). Since 64 < 252, None is impossible with a valid PayRef.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


Ok(TransferOutput {
address: transfer.destination_address,
blinded_amount: transfer.blinded_output_amount,
revealed_amount: transfer.revealed_output_amount,
memo: Some(memo),
})
},
None => Ok(TransferOutput {
address: transfer.destination_address,
blinded_amount: transfer.blinded_output_amount,
revealed_amount: transfer.revealed_output_amount,
memo: transfer.output_memo,
}),
})
.collect::<anyhow::Result<_>>()?;

let params = StealthTransferParams {
fee_input_selection: req.fee_input_selection,
input_selection: req.input_selection,
resource_address: req.resource_address,
max_fee: req.max_fee,
badge_usage: req.badge_usage,
outputs: req
.transfers
.into_iter()
.map(|transfer| TransferOutput {
address: transfer.destination_address,
blinded_amount: transfer.blinded_output_amount,
revealed_amount: transfer.revealed_output_amount,
memo: transfer.output_memo,
})
.collect(),
outputs,
is_dry_run: req.dry_run,
};
if let Err(err) = params.validate(network) {
Expand Down
1 change: 1 addition & 0 deletions applications/tari_walletd/web_ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@tari-project/wallet_jrpc_client": "workspace:*",
"@walletconnect/core": "^2.21.8",
"@walletconnect/utils": "^2.21.8",
"react-qr-code": "^2.0.18",
"buffer": "^6.0.3",
"cbor2": "^2.0.1",
"file-saver": "^2.0.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import { SubstateId, shortenString } from "@tari-project/typescript-bindings";
import { shortenString } from "@tari-project/typescript-bindings";
import { isSubstateIdString, shortenSubstateId, substateIdToString } from "@utils/helpers";
import CopyToClipboard from "@components/CopyToClipboard";

interface Props {
address: string;
display?: SubstateId | string;
display?: string;
Comment thread
sdbondi marked this conversation as resolved.
}

export default function CopyAddress({ address, display }: Props) {
Expand Down
36 changes: 36 additions & 0 deletions applications/tari_walletd/web_ui/src/components/Memo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright 2025 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause
import { Memo as TyMemo } from "@tari-project/typescript-bindings";
import { hexToU8 } from "cbor2/utils";
import StatusChip, { StatusChipColors } from "@components/StatusChip";
import CopyAddress from "@components/CopyAddress";
import CopyToClipboard from "@components/CopyToClipboard";

export type MemoProps = {
memo?: TyMemo | null;
Expand All @@ -17,6 +21,38 @@ export function Memo({ memo }: MemoProps) {
if ("Bytes" in memo) {
return <span>{memo ? Buffer.from(memo.Bytes).toString("hex") : "No Memo"}</span>;
}
if ("PayRefAndBytes" in memo) {
try {
const bytes = hexToU8(memo.PayRefAndBytes);
const len = bytes[0];
const payRef = tryDecodeUtf8(bytes.slice(1, len)) || Buffer.from(bytes.slice(1, len)).toString("hex");
const message = bytes.length >= 1 + len ? tryDecodeUtf8(bytes.slice(1 + len)) : [];
return (
<span>
{message}
<span style={{ marginLeft: "8px" }}>
{payRef && (
<StatusChip color={StatusChipColors.Blue} title="This UTXO has an attached Payment Ref.">
<CopyToClipboard copy={payRef} /> {payRef}{" "}
</StatusChip>
)}
</span>
</span>
);
} catch (e) {
console.warn("Failed to decode PayRefAndBytes memo:", e);
// ignore
}
}
Comment thread
sdbondi marked this conversation as resolved.

return <span>{JSON.stringify(memo)}</span>;
}

function tryDecodeUtf8(bytes: Uint8Array): string | null {
try {
let decoder = new TextDecoder();
return decoder.decode(bytes);
} catch (e) {
return null;
}
}
97 changes: 53 additions & 44 deletions applications/tari_walletd/web_ui/src/components/StatusChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,63 +23,72 @@
import { Chip, Avatar } from "@mui/material";
import { IoCheckmarkOutline, IoDiamondOutline, IoReload, IoHourglassOutline, IoCloseOutline } from "react-icons/io5";
import { useTheme } from "@mui/material/styles";
import type { TransactionStatus } from "@tari-project/typescript-bindings";

interface StatusChipProps {
status: TransactionStatus;
showTitle?: boolean;
}
export const StatusChipIcons = {
Checkmark: "Checkmark",
DiamondOutline: "DiamondOutline",
Reload: "Reload",
HourglassOutline: "HourglassOutline",
CloseOutline: "CloseOutline",
} as const;

const colorList: Record<string, string> = {
Accepted: "#5F9C91",
Pending: "#ECA86A",
DryRun: "#318EFA",
New: "#9D5CF9",
Rejected: "#DB7E7E",
InvalidTransaction: "#DB7E7E",
OnlyFeeAccepted: "#FFA500",
};
export type StatusChipIcon = (typeof StatusChipIcons)[keyof typeof StatusChipIcons];

export default function StatusChip({ status, showTitle = true }: StatusChipProps) {
const theme = useTheme();
export const StatusChipColors = {
Green: "#5F9C91",
Yellow: "#ECA86A",
Blue: "#318EFA",
Purple: "#9D5CF9",
Red: "#DB7E7E",
Orange: "#FFA500",
} as const;

const iconList: Record<string, JSX.Element> = {
Accepted: <IoCheckmarkOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
Pending: <IoHourglassOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
DryRun: <IoReload style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
New: <IoDiamondOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
Rejected: <IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
InvalidTransaction: <IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
OnlyFeeAccepted: (
<>
<IoCheckmarkOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />
<IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />
</>
),
};
export type StatusChipColor = (typeof StatusChipColors)[keyof typeof StatusChipColors];

let bgColor = colorList[status];
let background = null;
interface StatusChipProps {
icon?: StatusChipIcon;
children?: React.ReactNode;
title?: string;
color?: StatusChipColor;
}

if (status === "OnlyFeeAccepted") {
const leftColor = colorList["Accepted"];
const rightColor = colorList["Rejected"];
background = `linear-gradient(to right, ${leftColor} 50%, ${colorList["Rejected"]} 50%)`;
export default function StatusChip({ icon, title, color = StatusChipColors.Green, children }: StatusChipProps) {
const theme = useTheme();

let iconJsx;
if (icon) {
switch (icon) {
case StatusChipIcons.Checkmark:
iconJsx = <IoCheckmarkOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />;
break;
case StatusChipIcons.DiamondOutline:
iconJsx = <IoDiamondOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />;
break;
case StatusChipIcons.Reload:
iconJsx = <IoReload style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />;
break;
case StatusChipIcons.HourglassOutline:
iconJsx = <IoHourglassOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />;
break;
case StatusChipIcons.CloseOutline:
iconJsx = <IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />;
break;
}
}

if (!showTitle) {
let leftColor = colorList["Accepted"];
let rightColor = colorList["Rejected"];
let background = null;

return <Avatar sx={{ bgcolor: bgColor, height: 22, width: 22 }}>{iconList[status]}</Avatar>;
} else {
if (children) {
return (
<Chip
avatar={<Avatar sx={{ bgcolor: bgColor, background: background }}>{iconList[status]}</Avatar>}
label={status}
style={{ color: colorList[status], borderColor: colorList[status] }}
avatar={iconJsx ? <Avatar sx={{ bgcolor: color, background: background }}>{iconJsx}</Avatar> : undefined}
label={children}
style={{ color: color, borderColor: color }}
variant="outlined"
title={title}
/>
);
} else {
return <Avatar sx={{ bgcolor: color, height: 22, width: 22 }}>{iconJsx}</Avatar>;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2022. The Tari Project
//
// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
// following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
// disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
// following disclaimer in the documentation and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
// products derived from this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import { Chip, Avatar } from "@mui/material";
import { IoCheckmarkOutline, IoDiamondOutline, IoReload, IoHourglassOutline, IoCloseOutline } from "react-icons/io5";
import { useTheme } from "@mui/material/styles";
import type { TransactionStatus } from "@tari-project/typescript-bindings";

interface StatusChipProps {
status: TransactionStatus;
showTitle?: boolean;
}

const colorList: Record<string, string> = {
Accepted: "#5F9C91",
Pending: "#ECA86A",
DryRun: "#318EFA",
New: "#9D5CF9",
Rejected: "#DB7E7E",
InvalidTransaction: "#DB7E7E",
OnlyFeeAccepted: "#FFA500",
};

export default function TransactionsStatusChip({ status, showTitle = true }: StatusChipProps) {
const theme = useTheme();

const iconList: Record<string, JSX.Element> = {
Accepted: <IoCheckmarkOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
Pending: <IoHourglassOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
DryRun: <IoReload style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
New: <IoDiamondOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
Rejected: <IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
InvalidTransaction: <IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />,
OnlyFeeAccepted: (
<>
<IoCheckmarkOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />
<IoCloseOutline style={{ height: 14, width: 14 }} color={theme.palette.background.paper} />
</>
),
};

let bgColor = colorList[status];
let background = null;

if (status === "OnlyFeeAccepted") {
const leftColor = colorList["Accepted"];
const rightColor = colorList["Rejected"];
background = `linear-gradient(to right, ${leftColor} 50%, ${colorList["Rejected"]} 50%)`;
}

if (!showTitle) {
let leftColor = colorList["Accepted"];
let rightColor = colorList["Rejected"];

return <Avatar sx={{ bgcolor: bgColor, height: 22, width: 22 }}>{iconList[status]}</Avatar>;
} else {
return (
<Chip
avatar={<Avatar sx={{ bgcolor: bgColor, background: background }}>{iconList[status]}</Avatar>}
label={status}
style={{ color: colorList[status], borderColor: colorList[status] }}
variant="outlined"
/>
);
}
}
Loading
Loading