diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index cd7c895b52..a0043dc464 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -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"); + + 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::>()?; + 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) { diff --git a/applications/tari_walletd/web_ui/package.json b/applications/tari_walletd/web_ui/package.json index 3b1c948b1d..18dd9ad435 100644 --- a/applications/tari_walletd/web_ui/package.json +++ b/applications/tari_walletd/web_ui/package.json @@ -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", diff --git a/applications/tari_walletd/web_ui/src/components/CopyAddress.tsx b/applications/tari_walletd/web_ui/src/components/CopyAddress.tsx index 570a7be35e..62dcd3a0d0 100644 --- a/applications/tari_walletd/web_ui/src/components/CopyAddress.tsx +++ b/applications/tari_walletd/web_ui/src/components/CopyAddress.tsx @@ -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; } export default function CopyAddress({ address, display }: Props) { diff --git a/applications/tari_walletd/web_ui/src/components/Memo.tsx b/applications/tari_walletd/web_ui/src/components/Memo.tsx index 67420c35e4..c4e877450d 100644 --- a/applications/tari_walletd/web_ui/src/components/Memo.tsx +++ b/applications/tari_walletd/web_ui/src/components/Memo.tsx @@ -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; @@ -17,6 +21,38 @@ export function Memo({ memo }: MemoProps) { if ("Bytes" in memo) { return {memo ? Buffer.from(memo.Bytes).toString("hex") : "No Memo"}; } + 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 ( + + {message} + + {payRef && ( + + {payRef}{" "} + + )} + + + ); + } catch (e) { + console.warn("Failed to decode PayRefAndBytes memo:", e); + // ignore + } + } return {JSON.stringify(memo)}; } + +function tryDecodeUtf8(bytes: Uint8Array): string | null { + try { + let decoder = new TextDecoder(); + return decoder.decode(bytes); + } catch (e) { + return null; + } +} diff --git a/applications/tari_walletd/web_ui/src/components/StatusChip.tsx b/applications/tari_walletd/web_ui/src/components/StatusChip.tsx index 4725d80130..edf8314458 100644 --- a/applications/tari_walletd/web_ui/src/components/StatusChip.tsx +++ b/applications/tari_walletd/web_ui/src/components/StatusChip.tsx @@ -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 = { - 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 = { - Accepted: , - Pending: , - DryRun: , - New: , - Rejected: , - InvalidTransaction: , - OnlyFeeAccepted: ( - <> - - - - ), - }; +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 = ; + break; + case StatusChipIcons.DiamondOutline: + iconJsx = ; + break; + case StatusChipIcons.Reload: + iconJsx = ; + break; + case StatusChipIcons.HourglassOutline: + iconJsx = ; + break; + case StatusChipIcons.CloseOutline: + iconJsx = ; + break; + } } - if (!showTitle) { - let leftColor = colorList["Accepted"]; - let rightColor = colorList["Rejected"]; + let background = null; - return {iconList[status]}; - } else { + if (children) { return ( {iconList[status]}} - label={status} - style={{ color: colorList[status], borderColor: colorList[status] }} + avatar={iconJsx ? {iconJsx} : undefined} + label={children} + style={{ color: color, borderColor: color }} variant="outlined" + title={title} /> ); + } else { + return {iconJsx}; } } diff --git a/applications/tari_walletd/web_ui/src/components/TransactionsStatusChip.tsx b/applications/tari_walletd/web_ui/src/components/TransactionsStatusChip.tsx new file mode 100644 index 0000000000..678dc7487b --- /dev/null +++ b/applications/tari_walletd/web_ui/src/components/TransactionsStatusChip.tsx @@ -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 = { + 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 = { + Accepted: , + Pending: , + DryRun: , + New: , + Rejected: , + InvalidTransaction: , + OnlyFeeAccepted: ( + <> + + + + ), + }; + + 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 {iconList[status]}; + } else { + return ( + {iconList[status]}} + label={status} + style={{ color: colorList[status], borderColor: colorList[status] }} + variant="outlined" + /> + ); + } +} diff --git a/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/AccountDetails.tsx b/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/AccountDetails.tsx index e1bd727a9b..4a3742c1b4 100644 --- a/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/AccountDetails.tsx +++ b/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/AccountDetails.tsx @@ -23,7 +23,12 @@ import { DataTableCell } from "@components/StyledComponents"; import useAccountStore from "@store/accountStore"; import CopyAddress from "@components/CopyAddress"; -import { substateIdToString } from "@tari-project/typescript-bindings"; +import { + decodeOotleAddress, + encodeOotleAddress, + OotleAddress, + substateIdToString, +} from "@tari-project/typescript-bindings"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -31,8 +36,24 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import AccountName from "@/components/AccountName"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, + Tooltip, +} from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import { IoPaperPlaneOutline } from "react-icons/io5"; +import { useState } from "react"; +import QRCode from "react-qr-code"; +import Typography from "@mui/material/Typography"; function AccountDetails() { + const [payRefDialogOpen, setPayRefDialogOpen] = useState(false); const { account, address, setAccount } = useAccountStore(); if (!account) { @@ -45,6 +66,7 @@ function AccountDetails() { return ( + setPayRefDialogOpen(false)} /> @@ -67,6 +89,11 @@ function AccountDetails() { + + setPayRefDialogOpen(true)} color="primary"> + + + @@ -75,4 +102,74 @@ function AccountDetails() { ); } +type PayRefDialogProps = { + address: OotleAddress; + open: boolean; + onClose: () => void; +}; + +function PayRefDialog(props: PayRefDialogProps) { + const { address, open, onClose } = props; + + const [currentAddress, setCurrentAddress] = useState(address); + + const handleOnChange = (event: React.ChangeEvent) => { + const decoded = decodeOotleAddress(address); + console.log("Decoded address:", decoded); + decoded.payRef = event.target.value; + const addr = encodeOotleAddress(decoded); + console.log("Encoded address with payRef:", addr, addr == address); + setCurrentAddress(addr); + }; + + return ( + +
{}}> + Pay Ref Address + + + Generate an address with an embedded payment reference + +
+ + + + + + +
+
+ + + + +
+ ); +} + export default AccountDetails; diff --git a/applications/tari_walletd/web_ui/src/routes/Transactions/TransactionDetails.tsx b/applications/tari_walletd/web_ui/src/routes/Transactions/TransactionDetails.tsx index 89cc547f52..d79141f67e 100644 --- a/applications/tari_walletd/web_ui/src/routes/Transactions/TransactionDetails.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Transactions/TransactionDetails.tsx @@ -48,7 +48,7 @@ import Inputs from "./Inputs"; import Signers from "./Signers"; import ExecutionResults from "./ExecutionResults"; import FeeReceipt from "./FeeReceipt"; -import StatusChip from "@components/StatusChip"; +import TransactionsStatusChip from "@components/TransactionsStatusChip"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import Loading from "@components/Loading"; @@ -176,7 +176,7 @@ export default function TransactionDetails() { Status - + diff --git a/applications/tari_walletd/web_ui/src/routes/Transactions/Transactions.tsx b/applications/tari_walletd/web_ui/src/routes/Transactions/Transactions.tsx index f25aaf9ab2..7c26a23fce 100644 --- a/applications/tari_walletd/web_ui/src/routes/Transactions/Transactions.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Transactions/Transactions.tsx @@ -37,7 +37,7 @@ import { useTheme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import FetchStatusCheck from "@components/FetchStatusCheck"; -import StatusChip from "@components/StatusChip"; +import TransactionsStatusChip from "@components/TransactionsStatusChip"; import { DataTableCell } from "@components/StyledComponents"; import { useGetAllTransactions } from "@api/hooks/useTransactions"; import { emptyRows, handleChangePage, handleChangeRowsPerPage, formatCurrency } from "@utils/helpers"; @@ -100,7 +100,7 @@ export default function Transactions({ account }: { account: Account }) { - + {fee_receipt?.total_fees_paid ? formatCurrency(fee_receipt.total_fees_paid) : "--"} diff --git a/bindings/package.json b/bindings/package.json index 9dc83f6463..65d19a0f82 100644 --- a/bindings/package.json +++ b/bindings/package.json @@ -1,5 +1,7 @@ { "name": "@tari-project/typescript-bindings", + "version": "1.20.1", + "description": "TypeScript types synchronized to the Tari Ootle Rust codebase", "homepage": "https://github.com/tari-project/tari-ootle#readme", "bugs": { "url": "https://github.com/tari-project/tari-ootle/issues" @@ -16,7 +18,7 @@ "scripts": { "build-dist": "tsc --project tsconfig.prod.json", "build-dev": "tsc", - "build": "sh build.sh && pnpm run fmt && tsc --project tsconfig.prod.json && echo '✅ Build complete!'", + "build": "sh build.sh && pnpm run fmt && tsc --project tsconfig.prod.json && echo '✅ Build complete!'", "fmt": "pnpm npx prettier --write \"./**/*.{ts,tsx,css,json}\" --log-level=warn", "test": "vitest run" }, diff --git a/bindings/src/helpers/ootleAddress.ts b/bindings/src/helpers/ootleAddress.ts index 23bb15a8c2..3b79f5556e 100644 --- a/bindings/src/helpers/ootleAddress.ts +++ b/bindings/src/helpers/ootleAddress.ts @@ -5,12 +5,15 @@ import { OotleAddress } from "../types/OotleAddress"; import { Network } from "../types/Network"; import { bech32m, Decoded } from "bech32"; -const MAX_LENGTH_LIMIT = 118; +const PAY_REF_MAX_LENGTH = 64; + +const MAX_LENGTH_LIMIT = 118 + PAY_REF_MAX_LENGTH; export type DecodedOotleAddress = { network: Network; accountPublicKey: string; viewOnlyKey: string; + payRef: string | Uint8Array | null; }; export enum NetworkHrp { @@ -35,12 +38,23 @@ export function decodeOotleAddress(address: OotleAddress): DecodedOotleAddress { const { prefix, words }: Decoded = bech32m.decode(address, MAX_LENGTH_LIMIT); const data = bech32m.fromWords(words); - if (data.length != 64) { + if (data.length < 64) { throw new Error(`Invalid Ootle address length: ${data.length}, expected 64`); } const accountPublicKey = buf2hex(data.slice(0, 32)); const viewOnlyKey = buf2hex(data.slice(32, 64)); + const payRefBytes = new Uint8Array(data.slice(64)); + + // If the payRefSlice is utf-8 decodable, use it as payRef, otherwise null + let payRef: string | Uint8Array | null = null; + if (payRefBytes.length > 0) { + try { + payRef = new TextDecoder().decode(payRefBytes); + } catch (e) { + payRef = payRefBytes; + } + } let network: Network; switch (prefix) { @@ -71,6 +85,7 @@ export function decodeOotleAddress(address: OotleAddress): DecodedOotleAddress { network, accountPublicKey, viewOnlyKey, + payRef, }; } @@ -105,10 +120,16 @@ export function encodeOotleAddress(decoded: DecodedOotleAddress): OotleAddress { const accountPubKeyBytes = hex2buf(decoded.accountPublicKey); const viewOnlyKeyBytes = hex2buf(decoded.viewOnlyKey); + const payRefBytes = decoded.payRef + ? typeof decoded.payRef === "string" + ? new TextEncoder().encode(decoded.payRef) + : decoded.payRef + : new Uint8Array(); - const addressBytes = new Uint8Array(accountPubKeyBytes.length + viewOnlyKeyBytes.length); + const addressBytes = new Uint8Array(accountPubKeyBytes.length + viewOnlyKeyBytes.length + payRefBytes.length); addressBytes.set(accountPubKeyBytes, 0); addressBytes.set(viewOnlyKeyBytes, accountPubKeyBytes.length); + addressBytes.set(payRefBytes, accountPubKeyBytes.length + viewOnlyKeyBytes.length); const words = bech32m.toWords(addressBytes); return bech32m.encode(networkHrp, words, MAX_LENGTH_LIMIT) as OotleAddress; } @@ -119,7 +140,7 @@ function buf2hex(buffer: number[]) { return buffer.map((x) => x.toString(16).padStart(2, "0")).join(""); } -function hex2buf(string: string): Uint8Array { +function hex2buf(string: string): Uint8Array { // Check if Uint8Array.fromHex is available (most modern browsers) const fromHex = (Uint8Array as any).fromHex; if (typeof fromHex === "function") { diff --git a/bindings/src/index.ts b/bindings/src/index.ts index 88d400d69e..18b23dd988 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -81,6 +81,7 @@ export * from "./types/OotleAddress"; export * from "./types/Ordering"; export * from "./types/OutputStatus"; export * from "./types/OwnerRule"; +export * from "./types/PayRef"; export * from "./types/PedersenCommitmentBytes"; export * from "./types/PeerAddress"; export * from "./types/PrivateOutput"; diff --git a/bindings/src/types/Memo.ts b/bindings/src/types/Memo.ts index 6aad4efb2c..8e46ab4dfe 100644 --- a/bindings/src/types/Memo.ts +++ b/bindings/src/types/Memo.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Memo = { Message: string } | { Bytes: string }; +export type Memo = { U256: string } | { Message: string } | { Bytes: string } | { PayRefAndBytes: string }; diff --git a/bindings/src/types/PayRef.ts b/bindings/src/types/PayRef.ts new file mode 100644 index 0000000000..3fcfc0e2cf --- /dev/null +++ b/bindings/src/types/PayRef.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PayRef = string; diff --git a/bindings/test/ootleAddress.test.ts b/bindings/test/ootleAddress.test.ts index 06de7b1d0f..c61631e729 100644 --- a/bindings/test/ootleAddress.test.ts +++ b/bindings/test/ootleAddress.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { DecodedOotleAddress, encodeOotleAddress, decodeOotleAddress } from "../src"; describe("OotleAddress de/encoding", () => { - function makePayload() { + function makeDecodedAddress() { return { network: "igor", accountPublicKey: "a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0", @@ -14,7 +14,7 @@ describe("OotleAddress de/encoding", () => { } it("encodes and decodes a valid address", () => { - const sample = makePayload(); + const sample = makeDecodedAddress(); const address = encodeOotleAddress(sample); @@ -28,22 +28,43 @@ describe("OotleAddress de/encoding", () => { expect(parsed.accountPublicKey).toEqual(sample.accountPublicKey); }); + it("encodes and decodes address with payRef", () => { + const sample = makeDecodedAddress(); + const payRefString = "Invoice #12345"; + const check = encodeOotleAddress(sample); + sample.payRef = payRefString; + + const address = encodeOotleAddress(sample); + + expect(address).not.toEqual(check); + + let parsed: DecodedOotleAddress; + expect(() => { + parsed = decodeOotleAddress(address); + }).not.toThrow(); + + expect(parsed.network).toEqual(sample.network); + expect(parsed.viewOnlyKey).toEqual(sample.viewOnlyKey); + expect(parsed.accountPublicKey).toEqual(sample.accountPublicKey); + expect(parsed.payRef).toEqual(payRefString); + }); + it("throws on invalid address", () => { expect(() => decodeOotleAddress("invalidAddress")).toThrow(); }); it("throws on invalid public key hex length", () => { - let sample1 = makePayload(); + let sample1 = makeDecodedAddress(); sample1.accountPublicKey += "deadbeaf"; expect(() => encodeOotleAddress(sample1)).toThrow(); - let sample2 = makePayload(); + let sample2 = makeDecodedAddress(); sample2.accountPublicKey = sample2.accountPublicKey.slice(1); expect(() => encodeOotleAddress(sample2)).toThrow(); }); it("throws if address is tampered", () => { - const sample = makePayload(); + const sample = makeDecodedAddress(); const address = encodeOotleAddress(sample); // Tamper with the address by changing a character const tamperedAddress = address.slice(0, -1) + (address.slice(-1) === "a" ? "b" : "a"); @@ -61,7 +82,7 @@ describe("OotleAddress de/encoding", () => { }); it("throws on unsupported network prefix", () => { - const sample = makePayload(); + const sample = makeDecodedAddress(); // Temporarily change network to an unsupported one (sample as any).network = "unsupportednet"; expect(() => encodeOotleAddress(sample)).toThrow(); diff --git a/clients/javascript/wallet_daemon_client/package.json b/clients/javascript/wallet_daemon_client/package.json index 273aa8b7fe..7da35d1150 100644 --- a/clients/javascript/wallet_daemon_client/package.json +++ b/clients/javascript/wallet_daemon_client/package.json @@ -1,6 +1,6 @@ { "name": "@tari-project/wallet_jrpc_client", - "version": "1.11.0", + "version": "1.11.1", "description": "Tari wallet JSON-RPC client library", "homepage": "https://github.com/tari-project/tari-ootle#readme", "bugs": { diff --git a/clients/javascript/wallet_daemon_client/src/index.ts b/clients/javascript/wallet_daemon_client/src/index.ts index d4fe955fb1..c31506d4ff 100644 --- a/clients/javascript/wallet_daemon_client/src/index.ts +++ b/clients/javascript/wallet_daemon_client/src/index.ts @@ -17,7 +17,7 @@ import type { AccountSetDefaultRequest, AccountSetDefaultResponse, AccountsGetBalancesRequest, - AccountsGetBalancesResponse, + AccountsGetBalancesResponse, AccountsGetPayRefAddressRequest, AccountsGetPayRefAddressResponse, AccountsListRequest, AccountsListResponse, AccountsRenameRequest, @@ -177,6 +177,10 @@ export class WalletDaemonClient { return this.__invokeRpc("accounts.create", params); } + public accountsGetPayRefAddress(params: AccountsGetPayRefAddressRequest): Promise { + return this.__invokeRpc("accounts.get_pay_ref_address", params); + } + public accountsRename(params: AccountsRenameRequest): Promise { return this.__invokeRpc("accounts.rename", params); } diff --git a/clients/wallet_daemon_client/Cargo.toml b/clients/wallet_daemon_client/Cargo.toml index 23f299ac24..8b655f2a48 100644 --- a/clients/wallet_daemon_client/Cargo.toml +++ b/clients/wallet_daemon_client/Cargo.toml @@ -27,4 +27,4 @@ webauthn-rs-proto = { workspace = true } zeroize = { workspace = true, features = ["serde", "simd"] } [features] -ts = ["ts-rs"] +ts = ["ts-rs", "tari_ootle_wallet_sdk/ts", "tari_ootle_common_types/ts", "tari_ootle_address/ts"] diff --git a/crates/ootle_address/src/lib.rs b/crates/ootle_address/src/lib.rs index 002d879b12..59ab6641e6 100644 --- a/crates/ootle_address/src/lib.rs +++ b/crates/ootle_address/src/lib.rs @@ -3,5 +3,7 @@ mod hrp; mod ootle_address; +mod pay_ref; pub use ootle_address::*; +pub use pay_ref::*; diff --git a/crates/ootle_address/src/ootle_address.rs b/crates/ootle_address/src/ootle_address.rs index b8757656f9..5a0364779a 100644 --- a/crates/ootle_address/src/ootle_address.rs +++ b/crates/ootle_address/src/ootle_address.rs @@ -9,7 +9,10 @@ use tari_engine_types::{ConvertFromByteType, FromByteType, ToByteType}; use tari_ootle_common_types::{Network, NetworkParseError}; use tari_template_lib_types::{crypto::RistrettoPublicKeyBytes, InvalidByteLengthError}; -use crate::hrp::{hrp_from_network, network_from_hrp}; +use crate::{ + hrp::{hrp_from_network, network_from_hrp}, + pay_ref::PayRef, +}; #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, type = "string"))] @@ -17,26 +20,45 @@ pub struct OotleAddress { network: Network, view_only_key: RistrettoPublicKeyBytes, account_key: RistrettoPublicKeyBytes, + pay_ref: Option, } impl OotleAddress { - /// Byte length of the encoded address: network (1) + view_only_key (32) + account_key (32) - pub const BYTE_LENGTH: usize = 1 + 32 + 32; + /// Minimum byte length of the encoded address: network (1) + view_only_key (32) + account_key (32) + pay_ref len + /// (1) + const MIN_BYTE_LENGTH: usize = 1 + 32 + 32 + 1; pub fn new(network: Network, view_only_key: RistrettoPublicKeyBytes, account_key: RistrettoPublicKeyBytes) -> Self { Self { network, view_only_key, account_key, + pay_ref: None, } } + /// Adds a pay reference to the address. + pub fn with_pay_ref(mut self, pay_ref: PayRef) -> Self { + self.pay_ref = Some(pay_ref); + self + } + + /// Removes the pay reference from the address. + pub fn without_pay_ref(mut self) -> Self { + self.pay_ref = None; + self + } + pub fn validate(&self) -> Result<(), OotleAddressError> { self.to_account_key_ristretto()?; self.to_view_only_key_ristretto()?; Ok(()) } + pub const fn byte_length(&self) -> usize { + Self::MIN_BYTE_LENGTH + self.pay_ref_len() + } + pub fn network(&self) -> Network { self.network } @@ -49,6 +71,10 @@ impl OotleAddress { &self.account_key } + pub fn pay_ref(&self) -> Option<&PayRef> { + self.pay_ref.as_ref() + } + fn to_account_key_ristretto(&self) -> Result { self.account_key .try_from_byte_type() @@ -61,21 +87,21 @@ impl OotleAddress { .map_err(|_| OotleAddressError::InvalidPublicKey) } - pub fn to_byte_array(&self) -> [u8; Self::BYTE_LENGTH] { - let mut buf = [0u8; Self::BYTE_LENGTH]; - self.encode_to_writer(&mut buf.as_mut_slice()) - .expect("Buffer with sufficient capacity"); - buf - } - pub fn to_bytes(&self) -> Vec { - let mut buf = Vec::with_capacity(Self::BYTE_LENGTH); + let mut buf = Vec::with_capacity(Self::MIN_BYTE_LENGTH); self.encode_to_writer(&mut buf).unwrap(); buf } pub fn from_bytes(mut bytes: &[u8]) -> Result { - Self::decode_from_reader(&mut bytes) + let reader = &mut bytes; + let address = Self::decode_from_reader(reader)?; + if !reader.is_empty() { + return Err(OotleAddressError::BytesRemaining { + remaining: reader.len(), + }); + } + Ok(address) } pub fn decode_from_reader(reader: &mut R) -> Result { @@ -87,12 +113,51 @@ impl OotleAddress { let account_key = RistrettoPublicKeyBytes::from_bytes(&buf)?; reader.read_exact(&mut buf)?; let view_only_key = RistrettoPublicKeyBytes::from_bytes(&buf)?; - Ok(OotleAddress::new(network, view_only_key, account_key)) + + let mut buf = [0u8; 1]; + reader.read_exact(&mut buf)?; + let pay_ref_len = buf[0] as usize; + if pay_ref_len > PayRef::MAX_LEN { + return Err(OotleAddressError::InvalidAddressBytes(InvalidByteLengthError::new( + pay_ref_len, + PayRef::MAX_LEN, + ))); + } + + let pay_ref = if pay_ref_len > 0 { + let mut pr_buf = vec![0u8; pay_ref_len]; + reader + .read_exact(&mut pr_buf) + .map_err(|e| OotleAddressError::InvalidPayRefLengthSpecifier { + given_len: pay_ref_len, + source: e, + })?; + Some(PayRef::from_bytes(&pr_buf).expect("decode_from_reader: pay_ref_len checked and read")) + } else { + None + }; + + Ok(Self { + network, + view_only_key, + account_key, + pay_ref, + }) } pub fn encode_to_writer(&self, writer: &mut W) -> io::Result<()> { writer.write_all(&[self.network as u8])?; self.encode_keys_to_writer(writer)?; + // Write the length of the pay reference as a u8. + let pay_ref_len = u8::try_from(self.pay_ref_len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invariant violation: pay reference length exceeds 255 (u8::MAX)", + ) + })?; + writer.write_all(&[pay_ref_len])?; + + self.encode_payref_to_writer(writer)?; Ok(()) } @@ -102,11 +167,29 @@ impl OotleAddress { Ok(()) } + fn encode_payref_to_writer(&self, writer: &mut W) -> io::Result<()> { + if let Some(pay_ref) = &self.pay_ref { + writer.write_all(pay_ref.as_ref())?; + } + Ok(()) + } + + const fn encoded_bech32_payload_len(&self) -> usize { + // const_option_ops and const_option_basic are unstable, so we'll implement these operations manually + let kl = RistrettoPublicKeyBytes::length(); + match self.pay_ref { + Some(ref pr) => kl * 2 + pr.len(), + None => kl * 2, + } + } + pub fn encode_bech32_to_fmt(&self, f: &mut W) -> Result<(), OotleAddressError> { let hrp = hrp_from_network(self.network); - const KL: usize = RistrettoPublicKeyBytes::length(); - let mut buf = [0u8; KL * 2]; - self.encode_keys_to_writer(&mut buf.as_mut_slice())?; + let len = self.encoded_bech32_payload_len(); + let mut buf = vec![0u8; len]; + let writer = &mut buf.as_mut_slice(); + self.encode_keys_to_writer(writer)?; + self.encode_payref_to_writer(writer)?; bech32::encode_lower_to_fmt::(f, hrp, &buf)?; Ok(()) } @@ -115,22 +198,54 @@ impl OotleAddress { const KL: usize = RistrettoPublicKeyBytes::length(); let (hrp, data) = bech32::decode(s)?; let network = network_from_hrp(&hrp).ok_or(OotleAddressError::UnrecognisedHrp { hrp })?; - if data.len() != KL * 2 { - return Err(OotleAddressError::AddressIncorrectLength { - expected: RistrettoPublicKeyBytes::length() * 2, + if data.len() < KL * 2 { + return Err(OotleAddressError::AddressLengthTooShort { + minimum: KL * 2, actual: data.len(), }); } - let account_key = RistrettoPublicKeyBytes::from_bytes(&data[..KL])?; - let view_only_key = RistrettoPublicKeyBytes::from_bytes(&data[KL..KL * 2])?; - Ok(OotleAddress::new(network, view_only_key, account_key)) + let account_key = + RistrettoPublicKeyBytes::from_bytes(data.get(..KL).expect("decode_bech32: len checked (spend key)"))?; + let view_only_key = + RistrettoPublicKeyBytes::from_bytes(data.get(KL..KL * 2).expect("decode_bech32: len checked (view key)"))?; + + let mut address = OotleAddress::new(network, view_only_key, account_key); + if data.len() > KL * 2 { + let pay_ref = PayRef::from_bytes(data.get(KL * 2..).expect("decode_bech32: len checked (pay-ref)")).ok_or( + OotleAddressError::AddressLengthTooLong { + maximum: KL * 2 + PayRef::MAX_LEN, + actual: data.len(), + }, + )?; + address = address.with_pay_ref(pay_ref); + } + + Ok(address) + } + + const fn pay_ref_len(&self) -> usize { + // const_option_ops and const_option_basic are unstable, so we'll implement these operations manually + match self.pay_ref { + Some(ref pr) => pr.len(), + None => 0, + } } pub fn to_bech32_string(&self) -> String { - let mut s = String::with_capacity(119); + let pr_len = self.pay_ref_len(); + let mut s = String::with_capacity(119 + pr_len); self.encode_bech32_to_fmt(&mut s).unwrap(); s } + + pub fn into_ristretto_address(self) -> Result { + Ok(RistrettoOotleAddress { + network: self.network, + view_only_key: self.to_view_only_key_ristretto()?, + account_key: self.to_account_key_ristretto()?, + pay_ref: self.pay_ref, + }) + } } impl Display for OotleAddress { @@ -159,8 +274,14 @@ pub enum OotleAddressError { InvalidNetwork(#[from] NetworkParseError), #[error("Invalid address bytes: {0}")] InvalidAddressBytes(#[from] InvalidByteLengthError), - #[error("Address has incorrect length: expected {expected} bytes, got {actual} bytes")] - AddressIncorrectLength { expected: usize, actual: usize }, + #[error("Address length is too short: minimum {minimum} bytes, got {actual} bytes")] + AddressLengthTooShort { minimum: usize, actual: usize }, + #[error("Address length is too long: maximum {maximum} bytes, got {actual} bytes")] + AddressLengthTooLong { maximum: usize, actual: usize }, + #[error("{remaining} unexpected bytes remaining after decoding address")] + BytesRemaining { remaining: usize }, + #[error("Invalid pay reference length specifier: given length {given_len}, source error: {source}")] + InvalidPayRefLengthSpecifier { given_len: usize, source: io::Error }, #[error("Invalid public key")] InvalidPublicKey, #[error("IO error: {0}")] @@ -183,7 +304,7 @@ mod serde_impl { serializer.serialize_str(&s) } else { // Serialize as bytes - let bytes = self.to_byte_array(); + let bytes = self.to_bytes(); serializer.serialize_bytes(&bytes) } } @@ -210,6 +331,7 @@ pub struct RistrettoOotleAddress { pub network: Network, pub view_only_key: RistrettoPublicKey, pub account_key: RistrettoPublicKey, + pub pay_ref: Option, } impl RistrettoOotleAddress { @@ -218,9 +340,15 @@ impl RistrettoOotleAddress { network, view_only_key, account_key, + pay_ref: None, } } + pub fn with_pay_ref(mut self, pay_ref: PayRef) -> Self { + self.pay_ref = Some(pay_ref); + self + } + pub fn network(&self) -> Network { self.network } @@ -232,6 +360,19 @@ impl RistrettoOotleAddress { pub fn account_key(&self) -> &RistrettoPublicKey { &self.account_key } + + pub fn pay_ref(&self) -> Option<&PayRef> { + self.pay_ref.as_ref() + } + + pub fn into_byte_address(self) -> OotleAddress { + OotleAddress { + network: self.network, + view_only_key: self.view_only_key.to_byte_type(), + account_key: self.account_key.to_byte_type(), + pay_ref: self.pay_ref, + } + } } impl ConvertFromByteType for RistrettoOotleAddress { @@ -243,6 +384,7 @@ impl ConvertFromByteType for RistrettoOotleAddress { network: bytes.network, view_only_key: bytes.to_view_only_key_ristretto()?, account_key: bytes.to_account_key_ristretto()?, + pay_ref: bytes.pay_ref.clone(), }) } } @@ -251,11 +393,12 @@ impl ToByteType for RistrettoOotleAddress { type ByteType = OotleAddress; fn to_byte_type(&self) -> Self::ByteType { - OotleAddress::new( - self.network, - self.view_only_key.to_byte_type(), - self.account_key.to_byte_type(), - ) + OotleAddress { + network: self.network, + view_only_key: self.view_only_key.to_byte_type(), + account_key: self.account_key.to_byte_type(), + pay_ref: self.pay_ref.clone(), + } } } @@ -287,6 +430,7 @@ mod tests { assert_eq!(addr.network(), decoded.network()); assert_eq!(addr.view_only_key(), decoded.view_only_key()); assert_eq!(addr.account_public_key(), decoded.account_public_key()); + assert_eq!(addr.pay_ref(), None); } #[test] @@ -297,6 +441,7 @@ mod tests { assert_eq!(addr.network(), decoded.network()); assert_eq!(addr.view_only_key(), decoded.view_only_key()); assert_eq!(addr.account_public_key(), decoded.account_public_key()); + assert_eq!(addr.pay_ref(), None); } #[test] @@ -307,6 +452,87 @@ mod tests { assert_eq!(addr.network(), parsed.network()); assert_eq!(addr.view_only_key(), parsed.view_only_key()); assert_eq!(addr.account_public_key(), parsed.account_public_key()); + assert_eq!(addr.pay_ref(), None); + } + + mod with_pay_ref { + use std::iter; + + use super::*; + + #[test] + fn it_encodes_and_decodes_with_pay_ref() { + let pay_ref = PayRef::new_checked(vec![1; PayRef::MAX_LEN]).unwrap(); + let addr = sample(10).with_pay_ref(pay_ref.clone()); + let bytes = addr.to_bytes(); + let decoded = OotleAddress::from_bytes(&bytes).unwrap(); + assert_eq!(addr.network(), decoded.network()); + assert_eq!(addr.view_only_key(), decoded.view_only_key()); + assert_eq!(addr.account_public_key(), decoded.account_public_key()); + assert_eq!(addr.pay_ref(), Some(&pay_ref)); + } + + #[test] + fn it_errors_if_payref_length_is_inaccurate() { + let mut bytes = sample(11).to_bytes(); + // Encode pay_ref length to an incorrect value + let invalid_payref_len = PayRef::MAX_LEN as u8; + // Say we have MAX_LEN bytes but we have 0 + let payref_len_index = 1 + 32 + 32; // network (1) + account_key (32) + view_only_key (32) + bytes[payref_len_index] = invalid_payref_len; + let result = OotleAddress::from_bytes(&bytes); + assert!( + matches!( + result, + Err(OotleAddressError::InvalidPayRefLengthSpecifier { + given_len: PayRef::MAX_LEN, + .. + }) + ), + "{:?}", + result + ); + // Say we have 10 bytes but we have 12 + let mut bytes = sample(11).to_bytes(); + bytes[payref_len_index] = 10; + bytes.extend(iter::repeat_n(12, 12)); // add dummy pay_ref bytes + let result = OotleAddress::from_bytes(&bytes); + assert!( + matches!(result, Err(OotleAddressError::BytesRemaining { remaining: 2 })), + "Got: {:?}", + result + ); + + // Say we have 12 bytes but we have 10 + let mut bytes = sample(11).to_bytes(); + bytes[payref_len_index] = 12; + bytes.extend(iter::repeat_n(12, 10)); // add dummy pay_ref bytes + let result = OotleAddress::from_bytes(&bytes); + assert!( + matches!( + result, + Err(OotleAddressError::InvalidPayRefLengthSpecifier { given_len: 12, .. }) + ), + "Got: {:?}", + result + ); + } + + #[test] + fn it_errors_if_payref_is_too_large() { + let mut bytes = sample(11).to_bytes(); + // Encode pay_ref length to an incorrect value + let invalid_payref_len = PayRef::MAX_LEN as u8 + 1; + let payref_len_index = 1 + 32 + 32; // network (1) + account_key (32) + view_only_key (32) + bytes[payref_len_index] = invalid_payref_len; + bytes.extend(iter::repeat_n(12, invalid_payref_len as usize)); // add dummy pay_ref bytes + let result = OotleAddress::from_bytes(&bytes); + assert!( + matches!(result, Err(OotleAddressError::InvalidAddressBytes(_))), + "{:?}", + result + ); + } } #[cfg(feature = "serde")] @@ -322,6 +548,7 @@ mod tests { assert_eq!(addr.network(), deserialized.network()); assert_eq!(addr.view_only_key(), deserialized.view_only_key()); assert_eq!(addr.account_public_key(), deserialized.account_public_key()); + assert_eq!(addr.pay_ref(), None); } #[test] @@ -333,6 +560,36 @@ mod tests { assert_eq!(addr.network(), deserialized.network()); assert_eq!(addr.view_only_key(), deserialized.view_only_key()); assert_eq!(addr.account_public_key(), deserialized.account_public_key()); + assert_eq!(addr.pay_ref(), None); + } + + mod with_pay_ref { + use super::*; + + #[test] + fn it_serializes_to_json_with_pay_ref() { + let pay_ref = PayRef::new_checked(vec![10; PayRef::MAX_LEN]).unwrap(); + let addr = sample(7).with_pay_ref(pay_ref.clone()); + let json = serde_json::to_string(&addr).unwrap(); + let deserialized: OotleAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(addr.network(), deserialized.network()); + assert_eq!(addr.view_only_key(), deserialized.view_only_key()); + assert_eq!(addr.account_public_key(), deserialized.account_public_key()); + assert_eq!(addr.pay_ref(), Some(&pay_ref)); + } + + #[test] + fn it_serializes_to_bytes_with_pay_ref() { + let pay_ref = PayRef::new_checked(vec![40, 50, 60, 70]).unwrap(); + let addr = sample(8).with_pay_ref(pay_ref.clone()); + let bytes = bincode::serde::encode_to_vec(&addr, bincode::config::standard()).unwrap(); + let (deserialized, _): (OotleAddress, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + assert_eq!(addr.network(), deserialized.network()); + assert_eq!(addr.view_only_key(), deserialized.view_only_key()); + assert_eq!(addr.account_public_key(), deserialized.account_public_key()); + assert_eq!(addr.pay_ref(), Some(&pay_ref)); + } } } } diff --git a/crates/ootle_address/src/pay_ref.rs b/crates/ootle_address/src/pay_ref.rs new file mode 100644 index 0000000000..bdb1d661f6 --- /dev/null +++ b/crates/ootle_address/src/pay_ref.rs @@ -0,0 +1,85 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, type = "string"))] +pub struct PayRef(Box<[u8]>); + +impl PayRef { + /// Maximum length of a PayRef in bytes. This is chosen to easily fit within typical memo field constraints. + pub const MAX_LEN: usize = 64; + + pub fn new_checked>>(contents: T) -> Option { + let contents = contents.into(); + if contents.is_empty() || contents.len() > Self::MAX_LEN { + None + } else { + Some(Self(contents)) + } + } + + pub const fn len(&self) -> usize { + self.0.len() + } + + pub const fn is_empty(&self) -> bool { + self.0.len() == 0 + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + Self::new_checked(bytes) + } +} + +impl AsRef<[u8]> for PayRef { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From for Box<[u8]> { + fn from(pay_ref: PayRef) -> Self { + pay_ref.0 + } +} + +#[cfg(feature = "serde")] +mod serde_impl { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use tari_template_lib_types::{ + hex::{bytes_from_hex, bytes_to_hex}, + serde_helpers::BytesVisitor, + }; + + use super::PayRef; + + impl Serialize for PayRef { + fn serialize(&self, serializer: S) -> Result + where S: Serializer { + if serializer.is_human_readable() { + let hex = bytes_to_hex(&self.0); + serializer.serialize_str(&hex) + } else { + serializer.serialize_bytes(&self.0) + } + } + } + + impl<'de> Deserialize<'de> for PayRef { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> { + if deserializer.is_human_readable() { + let s = String::deserialize(deserializer)?; + let bytes = bytes_from_hex(&s).map_err(serde::de::Error::custom)?; + PayRef::new_checked(bytes).ok_or_else(|| serde::de::Error::custom("Invalid PayRef length")) + } else { + let bytes = deserializer.deserialize_bytes(BytesVisitor::new())?; + PayRef::new_checked(bytes).ok_or_else(|| serde::de::Error::custom("Invalid PayRef length")) + } + } + } +} diff --git a/crates/template_lib_types/src/error.rs b/crates/template_lib_types/src/error.rs index 6fc8afda10..868299ad52 100644 --- a/crates/template_lib_types/src/error.rs +++ b/crates/template_lib_types/src/error.rs @@ -11,6 +11,10 @@ pub struct InvalidByteLengthError { } impl InvalidByteLengthError { + pub fn new(size: usize, expected: usize) -> Self { + Self { size, expected } + } + pub fn actual_size(&self) -> usize { self.size } diff --git a/crates/wallet/crypto/src/encrypted_data.rs b/crates/wallet/crypto/src/encrypted_data.rs index 4f4dcd7813..58524dedca 100644 --- a/crates/wallet/crypto/src/encrypted_data.rs +++ b/crates/wallet/crypto/src/encrypted_data.rs @@ -216,7 +216,7 @@ fn decrypt_inner( let memo = if skip_memo || memo_bytes.is_empty() { None } else { - // Note any remaining bytes after memo decodes are discarded + // Note any remaining bytes after memo decoding are discarded let memo = Memo::decode_from(&mut memo_bytes).map_err(|e| WalletCryptoError::FailedDecryptData { details: format!("Failed to decode memo: {}", e), })?; diff --git a/crates/wallet/crypto/src/memo.rs b/crates/wallet/crypto/src/memo.rs index 21d5e12ea4..58d890c6fd 100644 --- a/crates/wallet/crypto/src/memo.rs +++ b/crates/wallet/crypto/src/memo.rs @@ -10,35 +10,52 @@ use tari_template_lib::types::{MaxBytes, MaxString}; /// https://github.com/tari-project/tari/blob/221d715e2447e6ca33e2ebcba11e915d24edac15/base_layer/transaction_components/src/transaction_components/memo_field.rs #[repr(u8)] enum MemoTag { + U256 = 0x01, Message = 0x10, Bytes = 0x11, + PayRefAndBytes = 0x12, } impl MemoTag { pub fn from_u8(value: u8) -> Option { match value { + 0x01 => Some(Self::U256), 0x10 => Some(Self::Message), 0x11 => Some(Self::Bytes), + 0x12 => Some(Self::PayRefAndBytes), _ => None, } } } const MAX_BYTES_LENGTH: usize = 253; + #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub enum Memo { + /// Fixed length 32-byte. This is supported for compatibility with Minotari U256 memos and allows + /// Ootle wallets to understand them. Note that only the custom encoding is compatible with Minotari, + /// not the serde encoding. + U256(#[cfg_attr(feature = "ts", ts(type = "string"))] MaxBytes<32>), /// UTF-8 encoded string message Message(#[cfg_attr(feature = "ts", ts(type = "string"))] MaxString), /// Arbitrary bytes Bytes(#[cfg_attr(feature = "ts", ts(type = "string"))] MaxBytes), + /// Payment reference and bytes delimited by a single byte length prefix. + /// Length-delimited format: [pay_ref_len][pay_ref][arb bytes (typically utf-8 message)] + PayRefAndBytes(#[cfg_attr(feature = "ts", ts(type = "string"))] MaxBytes), } impl Memo { - /// EncryptedData memo size (255) - 2 (enum tag + length (u8)) + /// EncryptedData memo size (255) - 2 (memo tag + length (u8)) pub const MAX_BYTES_LENGTH: usize = MAX_BYTES_LENGTH; + pub fn new_u256(value: [u8; 32]) -> Self { + let b = MaxBytes::new_checked(value).expect("32 < MAX_BYTES_LENGTH"); + Self::U256(b) + } + pub fn new_message(s: impl Into>) -> Option { let s = s.into(); let s = MaxString::new_checked(s)?; @@ -51,10 +68,49 @@ impl Memo { Some(Self::Bytes(b)) } + pub fn new_pay_ref_and_message>(pay_ref: P, msg: &str) -> Option { + Self::new_pay_ref_and_bytes(pay_ref, msg) + } + + /// Create a new `Memo::PayRefAndBytes` from a payment reference and message. If the combined length exceeds + /// the maximum allowed length, the message is truncated to fit. + pub fn new_pay_ref_and_message_truncate>(pay_ref: P, msg: &str) -> Option { + Self::new_pay_ref_and_bytes_truncate(pay_ref, msg.as_bytes()) + } + + pub fn new_pay_ref_and_bytes_truncate, B: AsRef<[u8]>>(pay_ref: P, msg_bytes: B) -> Option { + 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) + } + + pub fn new_pay_ref_and_bytes, B: AsRef<[u8]>>(pay_ref: P, msg_bytes: B) -> Option { + let pr = pay_ref.as_ref(); + let mb = msg_bytes.as_ref(); + // - 1 for length prefix byte + if mb.len() + pr.len() > Self::MAX_BYTES_LENGTH - 1 { + return None; + } + let mut combined = Vec::with_capacity(mb.len() + pr.len()); + combined.push(u8::try_from(pr.len()).expect("new_pay_ref_and_bytes: length checked")); + combined.extend_from_slice(pr); + combined.extend_from_slice(mb); + let b = MaxBytes::new_checked(combined)?; + Some(Self::PayRefAndBytes(b)) + } + pub fn len(&self) -> usize { match self { - Memo::Message(s) => s.len(), - Memo::Bytes(b) => b.len(), + Self::Message(s) => s.len(), + Self::Bytes(b) => b.len(), + Self::PayRefAndBytes(b) => b.len(), + Self::U256(b) => b.len(), } } @@ -62,10 +118,48 @@ impl Memo { self.len() == 0 } - pub fn as_message(&self) -> Option<&str> { + pub fn as_memo_message(&self) -> Option<&str> { + match self { + Self::Message(s) => Some(s), + Self::Bytes(_) | Self::PayRefAndBytes(_) | Self::U256(_) => None, + } + } + + pub fn as_memo_bytes(&self) -> Option<&[u8]> { match self { - Memo::Message(s) => Some(s), - Memo::Bytes(_) => None, + Self::Bytes(b) => Some(b.as_ref()), + Self::Message(_) | Self::PayRefAndBytes(_) | Self::U256(_) => None, + } + } + + pub fn as_pay_ref(&self) -> Option<&[u8]> { + match self { + Self::U256(_) | Self::Message(_) | Self::Bytes(_) => None, + Self::PayRefAndBytes(body) => split_len_prefixed(body).map(|(pay_ref, _)| pay_ref), + } + } + + pub fn as_pay_ref_and_message(&self) -> Option<(&[u8], &str)> { + self.as_pay_ref_and_bytes().and_then(|(pay_ref, msg_bytes)| { + let msg = str::from_utf8(msg_bytes).ok()?; + Some((pay_ref, msg)) + }) + } + + pub fn as_pay_ref_and_bytes(&self) -> Option<(&[u8], &[u8])> { + match self { + Self::U256(_) | Self::Message(_) | Self::Bytes(_) => None, + Self::PayRefAndBytes(body) => { + let (pay_ref, msg_bytes) = split_len_prefixed(body)?; + Some((pay_ref, msg_bytes)) + }, + } + } + + pub fn as_u256_bytes(&self) -> Option<&[u8]> { + match self { + Self::U256(b) => Some(b.as_ref()), + Self::Message(_) | Self::Bytes(_) | Self::PayRefAndBytes(_) => None, } } @@ -77,6 +171,10 @@ impl Memo { let len = self.len(); let len = u8::try_from(len).expect("len <= MAX_BYTES_LENGTH <= 255"); match self { + Self::U256(b) => { + writer.write_all(&[MemoTag::U256 as u8])?; + writer.write_all(b.as_ref())?; + }, Self::Message(s) => { writer.write_all(&[MemoTag::Message as u8])?; writer.write_all(&[len])?; @@ -87,6 +185,11 @@ impl Memo { writer.write_all(&[len])?; writer.write_all(b.as_ref())?; }, + Self::PayRefAndBytes(b) => { + writer.write_all(&[MemoTag::PayRefAndBytes as u8])?; + writer.write_all(&[len])?; + writer.write_all(b.as_ref())?; + }, } Ok(()) } @@ -104,24 +207,36 @@ impl Memo { return Ok(Self::new_bytes(buf).expect("length checked")); }; - let mut len_buf = [0u8; 1]; - reader.read_exact(&mut len_buf)?; - let len = len_buf[0] as usize; - if len > Self::MAX_BYTES_LENGTH { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("Memo length {} exceeds maximum {}", len, Self::MAX_BYTES_LENGTH), - )); - } - let mut buf = vec![0u8; len]; - reader.read_exact(&mut buf)?; match tag { + MemoTag::U256 => { + let mut arr = [0u8; 32]; + reader.read_exact(&mut arr)?; + Ok(Self::new_u256(arr)) + }, MemoTag::Message => { + let len = read_len_prefix(reader)?; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; let s = String::from_utf8(buf).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?; Ok(Self::new_message(s).expect("length checked (buf.len() <= MAX_BYTES_LENGTH)")) }, - MemoTag::Bytes => Ok(Self::new_bytes(buf).expect("length checked (buf.len() <= MAX_BYTES_LENGTH)")), + MemoTag::Bytes => { + let len = read_len_prefix(reader)?; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + Ok(Self::new_bytes(buf).expect("length checked (buf.len() <= MAX_BYTES_LENGTH)")) + }, + MemoTag::PayRefAndBytes => { + let len = read_len_prefix(reader)?; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + // Validate the length-delimited encoding + let _ = split_len_prefixed(&buf) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid MessageAndPayRef encoding"))?; + let bytes = MaxBytes::new_checked(buf).expect("length checked (buf.len() <= MAX_BYTES_LENGTH)"); + Ok(Self::PayRefAndBytes(bytes)) + }, } } } @@ -150,12 +265,66 @@ fn read_until_len_or_eof(mut buf: &mut [u8], reader: &mut R, max_le Ok(bytes_read) } +fn read_len_prefix(reader: &mut R) -> io::Result { + let mut len_buf = [0u8; 1]; + reader.read_exact(&mut len_buf)?; + let len = len_buf[0] as usize; + if len > MAX_BYTES_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Memo length specifier {} exceeds maximum {}", len, MAX_BYTES_LENGTH), + )); + } + Ok(len) +} + +fn split_len_prefixed(bytes: &[u8]) -> Option<(&[u8], &[u8])> { + if bytes.is_empty() { + return None; + } + + // Len prefixed + let len = bytes[0] as usize; + if len > bytes.len() - 1 { + return None; + } + + bytes[1..].split_at_checked(len) +} + #[cfg(test)] mod tests { use tari_template_lib::types::EncryptedData; use super::*; + #[test] + fn it_allows_empty_data() { + let memo = Memo::new_message("").unwrap(); + assert_eq!(memo.len(), 0); + // Encode/decode empty + let mut buf = Vec::new(); + memo.encode_into(&mut buf).unwrap(); + let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); + assert_eq!(memo, decoded); + + let memo = Memo::new_bytes(vec![]).unwrap(); + assert_eq!(memo.len(), 0); + // Encode/decode empty + let mut buf = Vec::new(); + memo.encode_into(&mut buf).unwrap(); + let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); + assert_eq!(memo, decoded); + + let memo = Memo::new_pay_ref_and_message([], "").unwrap(); + assert_eq!(memo.len(), 1); // 1 byte for the pay_ref length + // Encode/decode empty + let mut buf = Vec::new(); + memo.encode_into(&mut buf).unwrap(); + let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); + assert_eq!(memo, decoded); + } + #[test] fn it_returns_none_if_max_bytes_len_exceeded() { let bytes = vec![0u8; Memo::MAX_BYTES_LENGTH + 1]; @@ -165,6 +334,13 @@ mod tests { #[test] fn it_encodes_and_decodes() { + // U256 + let original = Memo::new_u256([1u8; 32]); + let mut buf = Vec::new(); + original.encode_into(&mut buf).unwrap(); + let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); + assert_eq!(original, decoded); + let original = Memo::new_message("Hello, world!").unwrap(); let mut buf = Vec::new(); original.encode_into(&mut buf).unwrap(); @@ -191,6 +367,16 @@ mod tests { original.encode_into(&mut buf).unwrap(); let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); assert_eq!(original, decoded); + + // PayRef and Message + let pay_ref = [1, 2, 3]; + let msg = "Payment for services"; + let original = Memo::new_pay_ref_and_message(pay_ref, msg).unwrap(); + let mut buf = Vec::new(); + original.encode_into(&mut buf).unwrap(); + let decoded = Memo::decode_from(&mut buf.as_slice()).unwrap(); + assert_eq!(original, decoded); + assert_eq!(original.as_memo_message(), decoded.as_memo_message()); } #[test] @@ -217,11 +403,11 @@ mod tests { let encoded = [vec![0u8, 5], vec![1u8; 5]].concat(); let decoded = Memo::decode_from(&mut encoded.as_slice()).unwrap(); // Includes the length byte in the bytes since some future unknown variant may not be length prefixed - assert_eq!(decoded, Memo::new_bytes(vec![5, 1, 1, 1, 1, 1]).unwrap()); + assert_eq!(decoded, Memo::new_bytes([5, 1, 1, 1, 1, 1]).unwrap()); } #[test] - fn it_borsh_encodes_to_max_bytes() { + fn it_encodes_to_max_bytes() { let bytes = vec![0u8; Memo::MAX_BYTES_LENGTH]; let memo = Memo::new_bytes(bytes).unwrap(); let mut buf = Vec::new(); @@ -229,4 +415,11 @@ mod tests { // We want the encoded memo to fit in the max size of an EncryptedData payload (i.e 255 bytes) assert!(buf.len() <= EncryptedData::max_size() - EncryptedData::min_size()); } + + #[test] + fn it_fails_to_decode_with_an_invalid_payref_length() { + // PayRef length (10) exceeds actual data length (5) + let encoded = [vec![MemoTag::PayRefAndBytes as u8, 10u8], vec![1u8; 5]].concat(); + let _err = Memo::decode_from(&mut encoded.as_slice()).unwrap_err(); + } } diff --git a/crates/wallet/sdk/src/apis/key_manager.rs b/crates/wallet/sdk/src/apis/key_manager.rs index c1864bc84b..ca07fd5a4b 100644 --- a/crates/wallet/sdk/src/apis/key_manager.rs +++ b/crates/wallet/sdk/src/apis/key_manager.rs @@ -203,6 +203,7 @@ impl<'a, TStore: WalletStore> KeyManagerApi<'a, TStore> { network: self.network, view_only_key: RistrettoPublicKey::from_secret_key(&view_only_key.key), account_key: RistrettoPublicKey::from_secret_key(&key.key), + pay_ref: None, }, view_only_key_id: key.as_key_id(), owner_key_id: key.as_key_id(), diff --git a/crates/wallet/sdk/tests/base_layer_compat.rs b/crates/wallet/sdk/tests/base_layer_compat.rs index 1d9350496d..2db1c40525 100644 --- a/crates/wallet/sdk/tests/base_layer_compat.rs +++ b/crates/wallet/sdk/tests/base_layer_compat.rs @@ -16,4 +16,13 @@ fn memo_is_compatible_with_bl_memo_field() { memo, Memo::new_bytes(memo_bytes[1..=Memo::MAX_BYTES_LENGTH].to_vec()).unwrap() ); + + let u256 = 100_000u64.into(); + let memo_field = MemoField::new_u256(u256); + let memo_bytes = memo_field.to_bytes(); + + let memo = Memo::decode_from(&mut memo_bytes.as_slice()).unwrap(); + let mut buf = [0u8; 32]; + u256.to_little_endian(buf.as_mut()); + assert_eq!(memo, Memo::new_u256(buf)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83c7d660a9..3b7cad4dab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: react-icons: specifier: ^4.12.0 version: 4.12.0(react@19.1.1) + react-qr-code: + specifier: ^2.0.18 + version: 2.0.18(react@19.1.1) react-router-dom: specifier: ^6.30.1 version: 6.30.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -2972,8 +2975,8 @@ packages: detect-browser@5.3.0: resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} - detect-libc@2.0.4: - resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} detect-node-es@1.1.0: @@ -3589,6 +3592,7 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4182,6 +4186,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qr.js@0.0.0: + resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} + qrcode@1.5.3: resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} engines: {node: '>=10.13.0'} @@ -4249,6 +4256,11 @@ packages: react-is@19.1.0: resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==} + react-qr-code@2.0.18: + resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==} + peerDependencies: + react: '*' + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -8891,7 +8903,7 @@ snapshots: detect-browser@5.3.0: {} - detect-libc@2.0.4: + detect-libc@2.1.2: optional: true detect-node-es@1.1.0: {} @@ -9676,7 +9688,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1 @@ -10460,6 +10472,8 @@ snapshots: punycode@2.3.1: {} + qr.js@0.0.0: {} + qrcode@1.5.3: dependencies: dijkstrajs: 1.0.3 @@ -10529,6 +10543,12 @@ snapshots: react-is@19.1.0: {} + react-qr-code@2.0.18(react@19.1.1): + dependencies: + prop-types: 15.8.1 + qr.js: 0.0.0 + react: 19.1.1 + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.24)(react@19.1.1):