diff --git a/Cargo.lock b/Cargo.lock index 97062a0beb..fff30f2cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11682,6 +11682,7 @@ dependencies = [ "tari_crypto", "tari_engine", "tari_engine_types", + "tari_ootle_address", "tari_ootle_app_utilities", "tari_ootle_common_types", "tari_ootle_template_metadata", diff --git a/applications/tari_walletd/Cargo.toml b/applications/tari_walletd/Cargo.toml index 85d5bd9b4c..12c9757427 100644 --- a/applications/tari_walletd/Cargo.toml +++ b/applications/tari_walletd/Cargo.toml @@ -14,6 +14,7 @@ tari_crypto = { workspace = true } tari_ootle_app_utilities = { workspace = true } tari_shutdown = { workspace = true } tari_ootle_wallet_crypto = { workspace = true, features = ["mmap-value-lookup"] } +tari_ootle_address = { workspace = true } tari_ootle_wallet_sdk = { workspace = true } tari_ootle_wallet_sdk_services = { workspace = true, features = ["indexer_client"] } tari_ootle_wallet_storage_sqlite = { workspace = true } diff --git a/applications/tari_walletd/src/handlers/address_book.rs b/applications/tari_walletd/src/handlers/address_book.rs new file mode 100644 index 0000000000..2ee40aae44 --- /dev/null +++ b/applications/tari_walletd/src/handlers/address_book.rs @@ -0,0 +1,109 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use std::str::FromStr; + +use anyhow::anyhow; +use axum_extra::headers::authorization::Bearer; +use tari_ootle_address::OotleAddress; +use tari_ootle_walletd_client::{ + permissions::JrpcPermission, + types::{ + AddressBookAddRequest, + AddressBookAddResponse, + AddressBookDeleteRequest, + AddressBookDeleteResponse, + AddressBookGetRequest, + AddressBookGetResponse, + AddressBookListRequest, + AddressBookListResponse, + AddressBookUpdateRequest, + AddressBookUpdateResponse, + }, +}; + +use crate::handlers::HandlerContext; + +fn validate_address(address: &str) -> Result<(), anyhow::Error> { + OotleAddress::from_str(address) + .map_err(|e| anyhow!("Invalid Ootle address '{address}': {e}"))?; + Ok(()) +} + +pub async fn handle_add( + context: &HandlerContext, + token: Option<&Bearer>, + req: AddressBookAddRequest, +) -> Result { + let sdk = context.wallet_sdk(); + context.check_auth(token, &[JrpcPermission::Admin])?; + + validate_address(&req.address)?; + + let entry = sdk + .address_book_api() + .add(&req.name, &req.address, req.memo.as_deref())?; + + Ok(AddressBookAddResponse { entry }) +} + +pub async fn handle_list( + context: &HandlerContext, + token: Option<&Bearer>, + _req: AddressBookListRequest, +) -> Result { + let sdk = context.wallet_sdk(); + context.check_auth(token, &[JrpcPermission::Admin])?; + + let entries = sdk.address_book_api().list()?; + + Ok(AddressBookListResponse { entries }) +} + +pub async fn handle_get( + context: &HandlerContext, + token: Option<&Bearer>, + req: AddressBookGetRequest, +) -> Result { + let sdk = context.wallet_sdk(); + context.check_auth(token, &[JrpcPermission::Admin])?; + + let entry = sdk.address_book_api().get(&req.name)?; + + Ok(AddressBookGetResponse { entry }) +} + +pub async fn handle_update( + context: &HandlerContext, + token: Option<&Bearer>, + req: AddressBookUpdateRequest, +) -> Result { + let sdk = context.wallet_sdk(); + context.check_auth(token, &[JrpcPermission::Admin])?; + + if let Some(ref address) = req.address { + validate_address(address)?; + } + + let entry = sdk.address_book_api().update( + &req.name, + req.new_name.as_deref(), + req.address.as_deref(), + req.memo.as_deref(), + )?; + + Ok(AddressBookUpdateResponse { entry }) +} + +pub async fn handle_delete( + context: &HandlerContext, + token: Option<&Bearer>, + req: AddressBookDeleteRequest, +) -> Result { + let sdk = context.wallet_sdk(); + context.check_auth(token, &[JrpcPermission::Admin])?; + + sdk.address_book_api().delete(&req.name)?; + + Ok(AddressBookDeleteResponse {}) +} diff --git a/applications/tari_walletd/src/handlers/mod.rs b/applications/tari_walletd/src/handlers/mod.rs index 0a04639848..a48784ecc3 100644 --- a/applications/tari_walletd/src/handlers/mod.rs +++ b/applications/tari_walletd/src/handlers/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause pub mod accounts; +pub mod address_book; pub mod auth; pub mod burn_proofs; pub mod confidential; diff --git a/applications/tari_walletd/src/server.rs b/applications/tari_walletd/src/server.rs index 87070ba7cb..09cba02ec6 100644 --- a/applications/tari_walletd/src/server.rs +++ b/applications/tari_walletd/src/server.rs @@ -27,6 +27,7 @@ use super::handlers::{HandlerContext, HandlerWithCookie, auth, stealth_utxos, su use crate::handlers::{ Handler, accounts, + address_book, auth::jwt::JwtApiError, burn_proofs, confidential, @@ -220,6 +221,14 @@ async fn handler( "get" => call_handler(context, value, token, burn_proofs::handle_get).await, _ => value.method_not_found(&value.method).into_response(), }, + Some(("address_book", method)) => match method { + "add" => call_handler(context, value, token, address_book::handle_add).await, + "list" => call_handler(context, value, token, address_book::handle_list).await, + "get" => call_handler(context, value, token, address_book::handle_get).await, + "update" => call_handler(context, value, token, address_book::handle_update).await, + "delete" => call_handler(context, value, token, address_book::handle_delete).await, + _ => value.method_not_found(&value.method).into_response(), + }, Some(("wallet", "get_info")) => call_handler(context, value, token, wallet::handle_get_info).await, _ => value.method_not_found(&value.method).into_response(), } diff --git a/applications/tari_walletd/web_ui/src/App.tsx b/applications/tari_walletd/web_ui/src/App.tsx index 468b844d10..795e385619 100644 --- a/applications/tari_walletd/web_ui/src/App.tsx +++ b/applications/tari_walletd/web_ui/src/App.tsx @@ -27,6 +27,7 @@ import Loading from "@components/Loading"; import AccessTokensLayout from "@routes/AccessTokens/AccessTokens"; import AccountDetails from "@routes/AccountDetails/AccountDetails"; import Accounts from "@routes/Accounts/Accounts"; +import AddressBookPage from "@routes/AddressBook/AddressBookPage"; import MyAssets from "@routes/AssetVault/Components/MyAssets"; import ErrorPage from "@routes/ErrorPage"; import Keys from "@routes/Keys/Keys"; @@ -115,6 +116,11 @@ export const breadcrumbRoutes = [ path: "/manifest", dynamic: false, }, + { + label: "Address Book", + path: "/address-book", + dynamic: false, + }, // { // label: "Flow Editor", // path: "/flow-editor", @@ -225,6 +231,7 @@ function App() { } /> } /> } /> + } /> {/*} />*/} , + activeIcon: , + link: "address-book", + }, { title: "Settings", icon: , diff --git a/applications/tari_walletd/web_ui/src/routes/AddressBook/AddressBookPage.tsx b/applications/tari_walletd/web_ui/src/routes/AddressBook/AddressBookPage.tsx new file mode 100644 index 0000000000..5853e81e21 --- /dev/null +++ b/applications/tari_walletd/web_ui/src/routes/AddressBook/AddressBookPage.tsx @@ -0,0 +1,249 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +import PageHeading from "@components/PageHeading"; +import { StyledPaper } from "@components/StyledComponents"; +import { + useAddressBookAdd, + useAddressBookDelete, + useAddressBookList, + useAddressBookUpdate, +} from "@api/hooks/useAddressBook"; +import type { AddressBookEntry } from "@tari-project/ootle-ts-bindings"; +import { validateOotleAddress } from "@tari-project/ootle-ts-bindings"; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import Grid from "@mui/material/Grid"; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { useState } from "react"; +import { MdAdd, MdDelete, MdEdit } from "react-icons/md"; + +interface EntryFormState { + name: string; + address: string; + memo: string; +} + +const EMPTY_FORM: EntryFormState = { name: "", address: "", memo: "" }; + +export default function AddressBookPage() { + const { data, isLoading } = useAddressBookList(); + const addMutation = useAddressBookAdd(); + const updateMutation = useAddressBookUpdate(); + const deleteMutation = useAddressBookDelete(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const entries = data?.entries ?? []; + + const openAddDialog = () => { + setEditingEntry(null); + setForm(EMPTY_FORM); + setFormError(null); + setDialogOpen(true); + }; + + const openEditDialog = (entry: AddressBookEntry) => { + setEditingEntry(entry); + setForm({ name: entry.name, address: entry.address, memo: entry.memo ?? "" }); + setFormError(null); + setDialogOpen(true); + }; + + const handleSave = async () => { + if (!form.name.trim()) { + setFormError("Name is required"); + return; + } + if (!form.address.trim()) { + setFormError("Address is required"); + return; + } + if (!validateOotleAddress(form.address.trim())) { + setFormError("Invalid Ootle address"); + return; + } + + try { + if (editingEntry) { + // Only send fields that actually changed. For `memo` specifically, + // we must distinguish "unchanged" (send undefined, backend skips) + // from "cleared" (send empty string, backend overwrites to ""): + // the previous `form.memo.trim() || undefined` collapsed both to + // undefined, so clearing a memo silently did nothing. The same + // treatment applies to `new_name` and `address` for symmetry — + // trimmed comparisons prevent whitespace-only "changes" from + // triggering pointless UPDATEs. + const trimmedName = form.name.trim(); + const trimmedAddress = form.address.trim(); + const trimmedMemo = form.memo.trim(); + const currentMemo = editingEntry.memo ?? ""; + await updateMutation.mutateAsync({ + name: editingEntry.name, + new_name: trimmedName !== editingEntry.name ? trimmedName : undefined, + address: trimmedAddress !== editingEntry.address ? trimmedAddress : undefined, + memo: trimmedMemo !== currentMemo ? trimmedMemo : undefined, + }); + } else { + await addMutation.mutateAsync({ + name: form.name.trim(), + address: form.address.trim(), + memo: form.memo.trim() || undefined, + }); + } + setDialogOpen(false); + } catch (e: any) { + // The backend returns `WalletStorageError::DuplicateName { name }` for + // unique-constraint violations on the address book name column. The + // JSON-RPC layer serializes this as an error string containing + // "DuplicateName"; we match on that exact token rather than the + // sqlite-level "UNIQUE constraint failed" phrasing so the UI stays + // decoupled from the underlying driver error text. + const msg = e?.cause?.message || e?.message || "Failed to save entry"; + if (msg.includes("DuplicateName")) { + setFormError("An entry with this name already exists"); + } else { + setFormError(msg); + } + } + }; + + const handleDelete = async (name: string) => { + try { + await deleteMutation.mutateAsync({ name }); + setDeleteConfirm(null); + } catch { + // Error handling via mutation state + } + }; + + const columns: GridColDef[] = [ + { field: "name", headerName: "Name", flex: 1, minWidth: 120 }, + { field: "address", headerName: "Address", flex: 2, minWidth: 200 }, + { field: "memo", headerName: "Memo", flex: 1, minWidth: 120 }, + { + field: "actions", + headerName: "", + width: 100, + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditDialog(params.row)}> + + + setDeleteConfirm(params.row.name)} color="error"> + + + + ), + }, + ]; + + return ( + <> + + Address Book + + + + + Saved Addresses + + + + + + + + {/* Add/Edit Dialog */} + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editingEntry ? "Edit Entry" : "Add Entry"} + + + {formError && {formError}} + setForm({ ...form, name: e.target.value })} + required + fullWidth + /> + setForm({ ...form, address: e.target.value })} + required + fullWidth + placeholder="otl_loc_..." + /> + setForm({ ...form, memo: e.target.value })} + fullWidth + multiline + rows={2} + /> + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteConfirm(null)}> + Delete Entry + + + Are you sure you want to delete "{deleteConfirm}" from your address book? + + + + + + + + + ); +} diff --git a/applications/tari_walletd/web_ui/src/routes/AssetVault/NFTs/steps/FormStep.tsx b/applications/tari_walletd/web_ui/src/routes/AssetVault/NFTs/steps/FormStep.tsx index 813f9b1ec5..867a38ab65 100644 --- a/applications/tari_walletd/web_ui/src/routes/AssetVault/NFTs/steps/FormStep.tsx +++ b/applications/tari_walletd/web_ui/src/routes/AssetVault/NFTs/steps/FormStep.tsx @@ -20,7 +20,8 @@ // 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 { Alert, Avatar, Divider, InputLabel, Stack } from "@mui/material"; +import { useAddressBookList } from "@api/hooks/useAddressBook"; +import { Alert, Autocomplete, Avatar, Divider, InputLabel, Stack } from "@mui/material"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import ListItemText from "@mui/material/ListItemText"; @@ -83,6 +84,12 @@ export default function FormStep({ onNftsChange, onPayerAccountChange, }: FormStepProps) { + const { data: addressBookData } = useAddressBookList(); + const addressBookOptions = (addressBookData?.entries ?? []).map((e) => ({ + label: `${e.name} (${e.address.slice(0, 16)}...)`, + value: e.address, + })); + const hasBatchSelection = preSelectedNfts?.length; const { transferFormState, disabled, updateFormValue } = useNftTransferStore(); const selectedNftIds = useMemo(() => new Set(transferFormState.nfts.map(nftIdToString)), [transferFormState.nfts]); @@ -157,20 +164,41 @@ export default function FormStep({ )} - { + if (reason === "input" || reason === "clear") { + const syntheticEvent = { + target: { name: "targetAccountAddress", value: newValue, validity: { valid: true } }, + } as unknown as React.ChangeEvent; + setFormValue(syntheticEvent); + } + }} + onChange={(_e, option) => { + if (option && typeof option !== "string") { + const syntheticEvent = { + target: { name: "targetAccountAddress", value: option.value, validity: { valid: true } }, + } as unknown as React.ChangeEvent; + setFormValue(syntheticEvent); + } + }} disabled={disabled} - error={transferFormState.targetAccountAddress !== "" && !isAddressValid} - helperText={ - transferFormState.targetAccountAddress !== "" && !isAddressValid - ? "Invalid address format. Expected format: otl_loc_..." - : "Enter the recipient's address (e.g., otl_loc_1enpsfkx...)" - } + renderInput={(params) => ( + + )} /> {hasBatchSelection ? ( diff --git a/applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/steps/FormStep.tsx b/applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/steps/FormStep.tsx index 6dab16d77f..3ee05182aa 100644 --- a/applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/steps/FormStep.tsx +++ b/applications/tari_walletd/web_ui/src/routes/AssetVault/Tokens/steps/FormStep.tsx @@ -21,7 +21,8 @@ // USE OF THIS SOFTWARE, SUCH DAMAGE. import CopyAddress from "@components/CopyAddress"; -import { Divider, InputAdornment, InputLabel, Stack, Typography } from "@mui/material"; +import { useAddressBookList } from "@api/hooks/useAddressBook"; +import { Autocomplete, Divider, InputAdornment, InputLabel, Stack, Typography } from "@mui/material"; import Button from "@mui/material/Button"; import CheckBox from "@mui/material/Checkbox"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -89,6 +90,12 @@ export default function FormStep({ onCheckboxFormValueChange, onUseBadgeChange, }: FormStepProps) { + const { data: addressBookData } = useAddressBookList(); + const addressBookOptions = (addressBookData?.entries ?? []).map((e) => ({ + label: `${e.name} (${e.address.slice(0, 16)}...)`, + value: e.address, + })); + const isConfidential = resource_type === "Confidential"; const isStealth = resource_type === "Stealth"; @@ -168,14 +175,36 @@ export default function FormStep({ )} - { + if (reason === "input" || reason === "clear") { + const syntheticEvent = { + target: { name: "address", value: newValue }, + } as React.ChangeEvent; + onFormValueChange(syntheticEvent); + } + }} + onChange={(_e, option) => { + if (option && typeof option !== "string") { + const syntheticEvent = { + target: { name: "address", value: option.value }, + } as React.ChangeEvent; + onFormValueChange(syntheticEvent); + } + }} disabled={disabled} + renderInput={(params) => ( + + )} /> diff --git a/applications/tari_walletd/web_ui/src/services/api/hooks/useAddressBook.ts b/applications/tari_walletd/web_ui/src/services/api/hooks/useAddressBook.ts new file mode 100644 index 0000000000..9526ae1c1d --- /dev/null +++ b/applications/tari_walletd/web_ui/src/services/api/hooks/useAddressBook.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + AddressBookAddRequest, + AddressBookUpdateRequest, + AddressBookDeleteRequest, +} from "@tari-project/ootle-ts-bindings"; +import { queryClient } from "@api/queryClient"; +import { addressBookAdd, addressBookDelete, addressBookList, addressBookUpdate } from "@utils/json_rpc"; + +const ADDRESS_BOOK_QUERY_KEY = ["address_book"]; + +export const useAddressBookList = () => { + return useQuery({ + queryKey: ADDRESS_BOOK_QUERY_KEY, + queryFn: () => addressBookList(), + }); +}; + +export const useAddressBookAdd = () => { + return useMutation({ + mutationFn: (params: AddressBookAddRequest) => addressBookAdd(params), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ADDRESS_BOOK_QUERY_KEY }); + }, + }); +}; + +export const useAddressBookUpdate = () => { + return useMutation({ + mutationFn: (params: AddressBookUpdateRequest) => addressBookUpdate(params), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ADDRESS_BOOK_QUERY_KEY }); + }, + }); +}; + +export const useAddressBookDelete = () => { + return useMutation({ + mutationFn: (params: AddressBookDeleteRequest) => addressBookDelete(params), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ADDRESS_BOOK_QUERY_KEY }); + }, + }); +}; diff --git a/applications/tari_walletd/web_ui/src/utils/json_rpc.ts b/applications/tari_walletd/web_ui/src/utils/json_rpc.ts index 5034de097f..ae5016bf52 100644 --- a/applications/tari_walletd/web_ui/src/utils/json_rpc.ts +++ b/applications/tari_walletd/web_ui/src/utils/json_rpc.ts @@ -114,6 +114,15 @@ import type { WebauthnStartRegisterResponse, WebRtcStartRequest, WebRtcStartResponse, + AddressBookAddRequest, + AddressBookAddResponse, + AddressBookListResponse, + AddressBookGetRequest, + AddressBookGetResponse, + AddressBookUpdateRequest, + AddressBookUpdateResponse, + AddressBookDeleteRequest, + AddressBookDeleteResponse, } from "@tari-project/ootle-ts-bindings"; import { WalletDaemonClient } from "@tari-project/wallet_jrpc_client"; import { jwtDecode } from "jwt-decode"; @@ -301,3 +310,19 @@ export const walletGetInfo = (): Promise => client().then // utxos export const stealthUtxosList = (request: StealthUtxosListRequest): Promise => client().then((c) => c.stealthUtxosList(request)); + +// address book +export const addressBookAdd = (request: AddressBookAddRequest): Promise => + client().then((c) => c.addressBookAdd(request)); + +export const addressBookList = (): Promise => + client().then((c) => c.addressBookList()); + +export const addressBookGet = (request: AddressBookGetRequest): Promise => + client().then((c) => c.addressBookGet(request)); + +export const addressBookUpdate = (request: AddressBookUpdateRequest): Promise => + client().then((c) => c.addressBookUpdate(request)); + +export const addressBookDelete = (request: AddressBookDeleteRequest): Promise => + client().then((c) => c.addressBookDelete(request)); diff --git a/bindings/src/types/wallet-types/AddressBookAddRequest.ts b/bindings/src/types/wallet-types/AddressBookAddRequest.ts new file mode 100644 index 0000000000..25a45c9498 --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookAddRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface AddressBookAddRequest { + name: string; + address: string; + memo?: string | null; +} diff --git a/bindings/src/types/wallet-types/AddressBookAddResponse.ts b/bindings/src/types/wallet-types/AddressBookAddResponse.ts new file mode 100644 index 0000000000..3ac95853f7 --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookAddResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddressBookEntry } from "../wallet-types/AddressBookEntry"; + +export interface AddressBookAddResponse { + entry: AddressBookEntry; +} diff --git a/bindings/src/types/wallet-types/AddressBookDeleteRequest.ts b/bindings/src/types/wallet-types/AddressBookDeleteRequest.ts new file mode 100644 index 0000000000..e3b74d1abc --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookDeleteRequest.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface AddressBookDeleteRequest { + name: string; +} diff --git a/bindings/src/types/wallet-types/AddressBookDeleteResponse.ts b/bindings/src/types/wallet-types/AddressBookDeleteResponse.ts new file mode 100644 index 0000000000..e8d9f7db03 --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookDeleteResponse.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 interface AddressBookDeleteResponse {} diff --git a/bindings/src/types/wallet-types/AddressBookEntry.ts b/bindings/src/types/wallet-types/AddressBookEntry.ts new file mode 100644 index 0000000000..1bab1f8d51 --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookEntry.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface AddressBookEntry { + id: number; + name: string; + address: string; + memo: string | null; +} diff --git a/bindings/src/types/wallet-types/AddressBookGetRequest.ts b/bindings/src/types/wallet-types/AddressBookGetRequest.ts new file mode 100644 index 0000000000..6fe94f1250 --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookGetRequest.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface AddressBookGetRequest { + name: string; +} diff --git a/bindings/src/types/wallet-types/AddressBookGetResponse.ts b/bindings/src/types/wallet-types/AddressBookGetResponse.ts new file mode 100644 index 0000000000..a5f7e8cb3d --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookGetResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddressBookEntry } from "../wallet-types/AddressBookEntry"; + +export interface AddressBookGetResponse { + entry: AddressBookEntry; +} diff --git a/bindings/src/types/wallet-types/AddressBookListRequest.ts b/bindings/src/types/wallet-types/AddressBookListRequest.ts new file mode 100644 index 0000000000..83fc6fc6fb --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookListRequest.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 interface AddressBookListRequest {} diff --git a/bindings/src/types/wallet-types/AddressBookListResponse.ts b/bindings/src/types/wallet-types/AddressBookListResponse.ts new file mode 100644 index 0000000000..1a43db50fe --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookListResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddressBookEntry } from "../wallet-types/AddressBookEntry"; + +export interface AddressBookListResponse { + entries: Array; +} diff --git a/bindings/src/types/wallet-types/AddressBookUpdateRequest.ts b/bindings/src/types/wallet-types/AddressBookUpdateRequest.ts new file mode 100644 index 0000000000..26f814472a --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookUpdateRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface AddressBookUpdateRequest { + name: string; + new_name?: string | null; + address?: string | null; + memo?: string | null; +} diff --git a/bindings/src/types/wallet-types/AddressBookUpdateResponse.ts b/bindings/src/types/wallet-types/AddressBookUpdateResponse.ts new file mode 100644 index 0000000000..d972295fad --- /dev/null +++ b/bindings/src/types/wallet-types/AddressBookUpdateResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddressBookEntry } from "../wallet-types/AddressBookEntry"; + +export interface AddressBookUpdateResponse { + entry: AddressBookEntry; +} diff --git a/bindings/src/wallet-types.ts b/bindings/src/wallet-types.ts index 8372722523..c758ceff43 100644 --- a/bindings/src/wallet-types.ts +++ b/bindings/src/wallet-types.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: BSD-3-Clause export * from "./types/wallet-types/AccountsListResponse"; +export * from "./types/wallet-types/AddressBookEntry"; +export * from "./types/wallet-types/AddressBookAddRequest"; +export * from "./types/wallet-types/AddressBookAddResponse"; +export * from "./types/wallet-types/AddressBookListRequest"; +export * from "./types/wallet-types/AddressBookListResponse"; +export * from "./types/wallet-types/AddressBookGetRequest"; +export * from "./types/wallet-types/AddressBookGetResponse"; +export * from "./types/wallet-types/AddressBookUpdateRequest"; +export * from "./types/wallet-types/AddressBookUpdateResponse"; +export * from "./types/wallet-types/AddressBookDeleteRequest"; +export * from "./types/wallet-types/AddressBookDeleteResponse"; export * from "./types/wallet-types/PublishTemplateMetadata"; export * from "./types/wallet-types/SignTemplateMetadataResponse"; export * from "./types/wallet-types/WalletGetInfoRequest"; diff --git a/clients/javascript/wallet_daemon_client/src/index.ts b/clients/javascript/wallet_daemon_client/src/index.ts index 2b9cc5b40c..a59e798f79 100644 --- a/clients/javascript/wallet_daemon_client/src/index.ts +++ b/clients/javascript/wallet_daemon_client/src/index.ts @@ -107,6 +107,15 @@ import type { WebRtcStartRequest, WebRtcStartResponse, AuthRefreshResponse, + AddressBookAddRequest, + AddressBookAddResponse, + AddressBookListResponse, + AddressBookGetRequest, + AddressBookGetResponse, + AddressBookUpdateRequest, + AddressBookUpdateResponse, + AddressBookDeleteRequest, + AddressBookDeleteResponse, } from "@tari-project/ootle-ts-bindings"; import { FetchRpcTransport, RpcErrorResponse, RpcResponse, RpcTransport } from "./transports"; @@ -369,6 +378,28 @@ export class WalletDaemonClient { return this.sendRequest("stealth_utxos.decrypt_value", params); } + // Address book + + public addressBookAdd(params: AddressBookAddRequest): Promise { + return this.sendRequest("address_book.add", params); + } + + public addressBookList(): Promise { + return this.sendRequest("address_book.list", {}); + } + + public addressBookGet(params: AddressBookGetRequest): Promise { + return this.sendRequest("address_book.get", params); + } + + public addressBookUpdate(params: AddressBookUpdateRequest): Promise { + return this.sendRequest("address_book.update", params); + } + + public addressBookDelete(params: AddressBookDeleteRequest): Promise { + return this.sendRequest("address_book.delete", params); + } + async sendRequest(method: string, params: object = null): Promise { const id = this.id++; let response = (await this.transport.sendRequest( diff --git a/clients/wallet_daemon_client/src/lib.rs b/clients/wallet_daemon_client/src/lib.rs index c69bbc6b84..ce8985e1d7 100644 --- a/clients/wallet_daemon_client/src/lib.rs +++ b/clients/wallet_daemon_client/src/lib.rs @@ -90,6 +90,16 @@ use crate::{ AccountsListResponse, AccountsRenameRequest, AccountsRenameResponse, + AddressBookAddRequest, + AddressBookAddResponse, + AddressBookDeleteRequest, + AddressBookDeleteResponse, + AddressBookGetRequest, + AddressBookGetResponse, + AddressBookListRequest, + AddressBookListResponse, + AddressBookUpdateRequest, + AddressBookUpdateResponse, AuthGetMethodRequest, AuthGetMethodResponse, AuthListSessionsRequest, @@ -706,6 +716,45 @@ impl WalletDaemonClient { self.send_request("webauthn.auth_start", req.borrow()).await } + // Address book + + /// Adds a new entry to the address book. + pub async fn address_book_add>( + &mut self, + req: T, + ) -> Result { + self.send_request("address_book.add", req.borrow()).await + } + + /// Lists all address book entries. + pub async fn address_book_list(&mut self) -> Result { + self.send_request("address_book.list", &AddressBookListRequest {}).await + } + + /// Gets a specific address book entry by name. + pub async fn address_book_get>( + &mut self, + req: T, + ) -> Result { + self.send_request("address_book.get", req.borrow()).await + } + + /// Updates an existing address book entry. + pub async fn address_book_update>( + &mut self, + req: T, + ) -> Result { + self.send_request("address_book.update", req.borrow()).await + } + + /// Deletes an address book entry by name. + pub async fn address_book_delete>( + &mut self, + req: T, + ) -> Result { + self.send_request("address_book.delete", req.borrow()).await + } + fn next_request_id(&mut self) -> i64 { self.request_id += 1; self.request_id diff --git a/clients/wallet_daemon_client/src/types.rs b/clients/wallet_daemon_client/src/types.rs index f42e66f3dd..72907f6a8a 100644 --- a/clients/wallet_daemon_client/src/types.rs +++ b/clients/wallet_daemon_client/src/types.rs @@ -47,6 +47,7 @@ use tari_ootle_wallet_sdk::{ crypto::{memo::Memo, pay_to::PayTo}, models::{ Account, + AddressBookEntry, AuthoredTemplateModel, DerivedKeyIndex, KeyBranch, @@ -1358,3 +1359,70 @@ pub struct SignTemplateMetadataResponse { #[cfg_attr(feature = "ts", ts(type = "string"))] pub metadata_hash: MetadataHash, } + +// -------------------------------- AddressBook -------------------------------- // + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookAddRequest { + pub name: String, + pub address: String, + #[serde(default)] + pub memo: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookAddResponse { + pub entry: AddressBookEntry, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookListRequest {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookListResponse { + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookGetRequest { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookGetResponse { + pub entry: AddressBookEntry, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookUpdateRequest { + pub name: String, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub address: Option, + #[serde(default)] + pub memo: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookUpdateResponse { + pub entry: AddressBookEntry, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookDeleteRequest { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookDeleteResponse {} diff --git a/crates/wallet/sdk/src/apis/address_book.rs b/crates/wallet/sdk/src/apis/address_book.rs new file mode 100644 index 0000000000..e21a82e039 --- /dev/null +++ b/crates/wallet/sdk/src/apis/address_book.rs @@ -0,0 +1,80 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_ootle_common_types::optional::IsNotFoundError; +use thiserror::Error; + +use crate::{ + models::AddressBookEntry, + storage::{CommittableStore, WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, +}; + +pub struct AddressBookApi<'a, TStore> { + store: &'a TStore, +} + +impl<'a, TStore> AddressBookApi<'a, TStore> +where TStore: WalletStore +{ + pub fn new(store: &'a TStore) -> Self { + Self { store } + } + + pub fn add( + &self, + name: &str, + address: &str, + memo: Option<&str>, + ) -> Result { + let mut tx = self.store.create_write_tx()?; + let entry = tx.address_book_insert(name, address, memo)?; + tx.commit()?; + Ok(entry) + } + + pub fn get(&self, name: &str) -> Result { + let mut tx = self.store.create_read_tx()?; + let entry = tx.address_book_get(name)?; + Ok(entry) + } + + pub fn list(&self) -> Result, AddressBookApiError> { + let mut tx = self.store.create_read_tx()?; + let entries = tx.address_book_get_all()?; + Ok(entries) + } + + pub fn update( + &self, + name: &str, + new_name: Option<&str>, + address: Option<&str>, + memo: Option<&str>, + ) -> Result { + let mut tx = self.store.create_write_tx()?; + let entry = tx.address_book_update(name, new_name, address, memo)?; + tx.commit()?; + Ok(entry) + } + + pub fn delete(&self, name: &str) -> Result<(), AddressBookApiError> { + let mut tx = self.store.create_write_tx()?; + tx.address_book_delete(name)?; + tx.commit()?; + Ok(()) + } +} + +#[derive(Debug, Error)] +pub enum AddressBookApiError { + #[error("Store error: {0}")] + StoreError(#[from] WalletStorageError), +} + +impl IsNotFoundError for AddressBookApiError { + fn is_not_found_error(&self) -> bool { + match self { + AddressBookApiError::StoreError(err) => err.is_not_found_error(), + } + } +} diff --git a/crates/wallet/sdk/src/apis/mod.rs b/crates/wallet/sdk/src/apis/mod.rs index 7c0aeb5c9a..8fc8b0bc5d 100644 --- a/crates/wallet/sdk/src/apis/mod.rs +++ b/crates/wallet/sdk/src/apis/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause pub mod accounts; +pub mod address_book; pub mod confidential_crypto; pub mod confidential_outputs; pub mod confidential_transfer; diff --git a/crates/wallet/sdk/src/models/address_book_entry.rs b/crates/wallet/sdk/src/models/address_book_entry.rs new file mode 100644 index 0000000000..144421d577 --- /dev/null +++ b/crates/wallet/sdk/src/models/address_book_entry.rs @@ -0,0 +1,11 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-types/"))] +pub struct AddressBookEntry { + pub id: i32, + pub name: String, + pub address: String, + pub memo: Option, +} diff --git a/crates/wallet/sdk/src/models/mod.rs b/crates/wallet/sdk/src/models/mod.rs index e2040e3147..6ce79c4903 100644 --- a/crates/wallet/sdk/src/models/mod.rs +++ b/crates/wallet/sdk/src/models/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause mod account; +mod address_book_entry; mod authored_template; mod confidential_output; mod config; @@ -20,6 +21,7 @@ mod wallet_transaction; mod webauthn_registration; pub use account::*; +pub use address_book_entry::*; pub use authored_template::*; pub use confidential_output::*; pub use config::Config; diff --git a/crates/wallet/sdk/src/sdk.rs b/crates/wallet/sdk/src/sdk.rs index b88a785f6e..98de2c147e 100644 --- a/crates/wallet/sdk/src/sdk.rs +++ b/crates/wallet/sdk/src/sdk.rs @@ -18,6 +18,7 @@ use zeroize::Zeroizing; use crate::{ apis::{ accounts::AccountsApi, + address_book::AddressBookApi, confidential_crypto::ConfidentialCryptoApi, confidential_outputs::ConfidentialOutputsApi, confidential_transfer::ConfidentialTransferApi, @@ -222,6 +223,10 @@ impl WalletSdk { NonFungibleTokensApi::new(&self.store) } + pub fn address_book_api(&self) -> AddressBookApi<'_, TSpec::Store> { + AddressBookApi::new(&self.store) + } + pub fn template_api(&self) -> TemplateApi<'_, TSpec::Store> { TemplateApi::new(&self.store) } diff --git a/crates/wallet/sdk/src/storage/error.rs b/crates/wallet/sdk/src/storage/error.rs index 9932c9963a..655cdead86 100644 --- a/crates/wallet/sdk/src/storage/error.rs +++ b/crates/wallet/sdk/src/storage/error.rs @@ -36,6 +36,8 @@ pub enum WalletStorageError { EncryptionError { operation: &'static str, details: String }, #[error("Decryption error {operation}: {details}")] DecryptionError { operation: &'static str, details: String }, + #[error("DuplicateName: an entry named {name:?} already exists")] + DuplicateName { name: String }, } impl IsNotFoundError for WalletStorageError { diff --git a/crates/wallet/sdk/src/storage/reader.rs b/crates/wallet/sdk/src/storage/reader.rs index 283d20e852..ce390f061c 100644 --- a/crates/wallet/sdk/src/storage/reader.rs +++ b/crates/wallet/sdk/src/storage/reader.rs @@ -20,6 +20,7 @@ use webauthn_rs::prelude::Passkey; use crate::{ models::{ Account, + AddressBookEntry, AuthoredTemplateModel, ConfidentialOutputModel, Config, @@ -223,4 +224,8 @@ pub trait WalletStoreReader { &mut self, batch_size: usize, ) -> Result>, WalletStorageError>; + + // Address book + fn address_book_get(&mut self, name: &str) -> Result; + fn address_book_get_all(&mut self) -> Result, WalletStorageError>; } diff --git a/crates/wallet/sdk/src/storage/writer.rs b/crates/wallet/sdk/src/storage/writer.rs index 104cd72165..15f14f1f8d 100644 --- a/crates/wallet/sdk/src/storage/writer.rs +++ b/crates/wallet/sdk/src/storage/writer.rs @@ -25,6 +25,7 @@ use webauthn_rs::prelude::Passkey; use crate::{ models::{ AccountUpdate, + AddressBookEntry, AuthoredTemplateModel, ConfidentialOutputModel, ImportedKeyId, @@ -225,6 +226,22 @@ pub trait WalletStoreWriter: CommittableStore { tag: UtxoTag, public_nonce: RistrettoPublicKeyBytes, ) -> Result<(), WalletStorageError>; + + // Address book + fn address_book_insert( + &mut self, + name: &str, + address: &str, + memo: Option<&str>, + ) -> Result; + fn address_book_update( + &mut self, + name: &str, + new_name: Option<&str>, + address: Option<&str>, + memo: Option<&str>, + ) -> Result; + fn address_book_delete(&mut self, name: &str) -> Result<(), WalletStorageError>; } pub trait WalletEventStoreWriter { diff --git a/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/down.sql b/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/down.sql new file mode 100644 index 0000000000..eba681ac18 --- /dev/null +++ b/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS address_book; diff --git a/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/up.sql b/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/up.sql new file mode 100644 index 0000000000..3a8d0c56db --- /dev/null +++ b/crates/wallet/storage_sqlite/migrations/2026-04-10-120000_address_book/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE address_book ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + address TEXT NOT NULL, + memo TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); diff --git a/crates/wallet/storage_sqlite/src/models/address_book_entry.rs b/crates/wallet/storage_sqlite/src/models/address_book_entry.rs new file mode 100644 index 0000000000..71dda372ad --- /dev/null +++ b/crates/wallet/storage_sqlite/src/models/address_book_entry.rs @@ -0,0 +1,41 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use diesel::{AsChangeset, Identifiable, Queryable, dsl}; +use time::PrimitiveDateTime; + +use crate::schema::address_book; + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = address_book)] +pub struct AddressBookEntry { + pub id: i32, + pub name: String, + pub address: String, + pub memo: Option, + pub created_at: PrimitiveDateTime, + pub updated_at: PrimitiveDateTime, +} + +/// Diesel changeset used by `address_book_update` so the three mutable columns +/// (`name`, `address`, `memo`) are written in a single UPDATE statement. Each +/// field is wrapped in `Option` so callers can pass `None` to leave the column +/// untouched without issuing a separate query per field. +/// +/// `memo` is double-`Option` deliberately: the outer `Option` controls whether +/// the field is part of the UPDATE at all, and the inner `Option<&str>` +/// matches the nullable column so it can be set to `NULL` to clear a +/// previously-stored memo. +/// +/// `updated_at` is `dsl::now` (a zero-sized type, not `Option`) so every +/// UPDATE bumps the timestamp — matching the `StealthOutputUpdate` pattern in +/// `stealth_output.rs`. `dsl::now` cannot be wrapped in `Option` because it +/// is a SQL expression, not a value. +#[derive(Debug, AsChangeset)] +#[diesel(table_name = address_book)] +pub struct AddressBookEntryChangeset<'a> { + pub name: Option<&'a str>, + pub address: Option<&'a str>, + pub memo: Option>, + pub updated_at: dsl::now, +} diff --git a/crates/wallet/storage_sqlite/src/models/mod.rs b/crates/wallet/storage_sqlite/src/models/mod.rs index ec627dcad1..99b989aa9e 100644 --- a/crates/wallet/storage_sqlite/src/models/mod.rs +++ b/crates/wallet/storage_sqlite/src/models/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause mod account; +mod address_book_entry; mod config; @@ -21,6 +22,7 @@ mod utxo_process_queue; mod webauthn_registrations; pub use account::*; +pub use address_book_entry::*; pub use authored_template::*; pub use confidential_output::*; pub use config::*; diff --git a/crates/wallet/storage_sqlite/src/reader.rs b/crates/wallet/storage_sqlite/src/reader.rs index 29fa527c8c..ceeacf5be5 100644 --- a/crates/wallet/storage_sqlite/src/reader.rs +++ b/crates/wallet/storage_sqlite/src/reader.rs @@ -32,6 +32,7 @@ use tari_ootle_transaction::TransactionId; use tari_ootle_wallet_sdk::{ models::{ Account, + AddressBookEntry, AuthoredTemplateModel, ConfidentialOutputModel, Config, @@ -1528,6 +1529,47 @@ impl WalletStoreReader for ReadTransaction<'_> { Ok(result) } + + fn address_book_get(&mut self, name: &str) -> Result { + use crate::schema::address_book; + + let row = address_book::table + .filter(address_book::name.eq(name)) + .first::(self.connection()) + .optional() + .map_err(|e| WalletStorageError::general("address_book_get", e))? + .ok_or_else(|| WalletStorageError::NotFound { + operation: "address_book_get", + entity: "address_book_entry".to_string(), + key: name.to_string(), + })?; + + Ok(AddressBookEntry { + id: row.id, + name: row.name, + address: row.address, + memo: row.memo, + }) + } + + fn address_book_get_all(&mut self) -> Result, WalletStorageError> { + use crate::schema::address_book; + + let rows = address_book::table + .order(address_book::name.asc()) + .load::(self.connection()) + .map_err(|e| WalletStorageError::general("address_book_get_all", e))?; + + Ok(rows + .into_iter() + .map(|row| AddressBookEntry { + id: row.id, + name: row.name, + address: row.address, + memo: row.memo, + }) + .collect()) + } } impl Drop for ReadTransaction<'_> { diff --git a/crates/wallet/storage_sqlite/src/schema.rs b/crates/wallet/storage_sqlite/src/schema.rs index 23fc0f500e..0746b596b8 100644 --- a/crates/wallet/storage_sqlite/src/schema.rs +++ b/crates/wallet/storage_sqlite/src/schema.rs @@ -263,6 +263,17 @@ diesel::table! { } } +diesel::table! { + address_book (id) { + id -> Integer, + name -> Text, + address -> Text, + memo -> Nullable, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + diesel::joinable!(confidential_outputs -> accounts (account_id)); diesel::joinable!(confidential_outputs -> vaults (vault_id)); diesel::joinable!(non_fungible_tokens -> vaults (vault_id)); @@ -278,6 +289,7 @@ diesel::joinable!(webauthn_registration_passkeys -> webauthn_registrations (regi diesel::allow_tables_to_appear_in_same_query!( accounts, + address_book, authored_templates, confidential_outputs, config, diff --git a/crates/wallet/storage_sqlite/src/writer.rs b/crates/wallet/storage_sqlite/src/writer.rs index 44a39c87fb..2197d1b0d1 100644 --- a/crates/wallet/storage_sqlite/src/writer.rs +++ b/crates/wallet/storage_sqlite/src/writer.rs @@ -39,6 +39,7 @@ use tari_ootle_transaction::{Transaction, TransactionId}; use tari_ootle_wallet_sdk::{ models::{ AccountUpdate, + AddressBookEntry, AuthoredTemplateModel, ConfidentialOutputModel, ImportedKeyId, @@ -77,7 +78,7 @@ use webauthn_rs::prelude::Passkey; use crate::{ diesel::ExpressionMethods, models, - models::StealthOutputUpdate, + models::{AddressBookEntryChangeset, StealthOutputUpdate}, reader::ReadTransaction, serialization::{deserialize_hex_try_from, deserialize_json, serialize_hex, serialize_json}, }; @@ -1661,6 +1662,135 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } + + // Address book + + fn address_book_insert( + &mut self, + name: &str, + address: &str, + memo: Option<&str>, + ) -> Result { + const OPERATION: &str = "address_book_insert"; + use crate::schema::address_book; + + diesel::insert_into(address_book::table) + .values(( + address_book::name.eq(name), + address_book::address.eq(address), + address_book::memo.eq(memo), + )) + .execute(self.connection()) + .map_err(|e| map_address_book_error(OPERATION, name, e))?; + + let row = address_book::table + .filter(address_book::name.eq(name)) + .first::(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + Ok(AddressBookEntry { + id: row.id, + name: row.name, + address: row.address, + memo: row.memo, + }) + } + + fn address_book_update( + &mut self, + name: &str, + new_name: Option<&str>, + address: Option<&str>, + memo: Option<&str>, + ) -> Result { + const OPERATION: &str = "address_book_update"; + use crate::schema::address_book; + + // Build a single changeset so all mutated columns (plus the + // bookkeeping `updated_at`) are written in one UPDATE statement + // instead of one query per field. The previous implementation issued + // up to three separate UPDATEs, each with its own round-trip and its + // own `updated_at` bump, leaving the row timestamps inconsistent with + // the caller's intent. + // + // `memo` is mapped `Some(s) -> Some(Some(s))` so the column is set to + // the supplied string (including the empty string used by the UI to + // clear a previously-stored memo). `None` on any field means "leave + // the column untouched". + let changeset = AddressBookEntryChangeset { + name: new_name, + address, + memo: memo.map(Some), + updated_at: dsl::now, + }; + + let num_affected = diesel::update(address_book::table.filter(address_book::name.eq(name))) + .set(changeset) + .execute(self.connection()) + .map_err(|e| map_address_book_error(OPERATION, new_name.unwrap_or(name), e))?; + + if num_affected == 0 { + return Err(WalletStorageError::NotFound { + operation: OPERATION, + entity: "address_book_entry".to_string(), + key: name.to_string(), + }); + } + + // After a successful rename the row now lives under `new_name`, so + // the re-read must query by the post-update name. + let lookup_name = new_name.unwrap_or(name); + + let row = address_book::table + .filter(address_book::name.eq(lookup_name)) + .first::(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + Ok(AddressBookEntry { + id: row.id, + name: row.name, + address: row.address, + memo: row.memo, + }) + } + + fn address_book_delete(&mut self, name: &str) -> Result<(), WalletStorageError> { + use crate::schema::address_book; + + let num_affected = diesel::delete(address_book::table.filter(address_book::name.eq(name))) + .execute(self.connection()) + .map_err(|e| WalletStorageError::general("address_book_delete", e))?; + + if num_affected == 0 { + return Err(WalletStorageError::NotFound { + operation: "address_book_delete", + entity: "address_book_entry".to_string(), + key: name.to_string(), + }); + } + + Ok(()) + } +} + +/// Maps diesel errors from address_book writes into the typed +/// [`WalletStorageError::DuplicateName`] variant when the failure is a +/// SQLite `UNIQUE` constraint violation on the `address_book.name` column. +/// Every other error falls through to the generic path. +/// +/// Matching on the typed [`DatabaseErrorKind::UniqueViolation`] keeps the +/// wallet SDK decoupled from the exact driver error text (e.g. sqlite's +/// `"UNIQUE constraint failed: address_book.name"`), so the UI can reliably +/// detect duplicate-name failures by matching on the `DuplicateName` token +/// rather than grepping the underlying driver message. +fn map_address_book_error(operation: &'static str, name: &str, err: diesel::result::Error) -> WalletStorageError { + use diesel::result::{DatabaseErrorKind, Error as DieselError}; + match err { + DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => { + WalletStorageError::DuplicateName { name: name.to_string() } + }, + other => WalletStorageError::general(operation, other), + } } impl WalletEventStoreWriter for WriteTransaction<'_> { diff --git a/crates/wallet/storage_sqlite/tests/address_book.rs b/crates/wallet/storage_sqlite/tests/address_book.rs new file mode 100644 index 0000000000..5441224900 --- /dev/null +++ b/crates/wallet/storage_sqlite/tests/address_book.rs @@ -0,0 +1,191 @@ +// Copyright 2026 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +//! Storage-level tests for the `address_book_*` writer/reader methods. +//! +//! These exercise the behaviour the walletd JSON-RPC layer and the web-ui +//! `AddressBookPage` rely on: +//! * a single `address_book_update` UPDATE statement applies rename + +//! address + memo in one round-trip (previously three separate UPDATEs), +//! * passing `memo = Some("")` actually clears the previously-stored memo +//! (the UI relies on this to distinguish "unchanged" from "cleared"), +//! * passing `memo = None` leaves the existing memo untouched, and +//! * inserting or renaming onto a name that already exists surfaces the +//! typed [`WalletStorageError::DuplicateName`] variant — not a generic +//! `GeneralFailure` wrapping a sqlite "UNIQUE constraint failed" string. + +use tari_ootle_wallet_sdk::storage::{ + CommittableStore, + WalletStorageError, + WalletStoreReader, + WalletStoreWriter, + WriteableWalletStore, +}; +use tari_ootle_wallet_storage_sqlite::SqliteWalletStore; + +fn open_store() -> SqliteWalletStore { + let db = SqliteWalletStore::try_open(":memory:").unwrap(); + db.run_migrations().unwrap(); + db +} + +#[test] +fn insert_and_get_round_trips() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + let inserted = tx + .address_book_insert("alice", "otl_loc_alice", Some("work wallet")) + .unwrap(); + tx.commit().unwrap(); + + assert_eq!(inserted.name, "alice"); + assert_eq!(inserted.address, "otl_loc_alice"); + assert_eq!(inserted.memo.as_deref(), Some("work wallet")); + + let mut tx = db.create_write_tx().unwrap(); + let fetched = tx.address_book_get("alice").unwrap(); + assert_eq!(fetched.address, "otl_loc_alice"); + assert_eq!(fetched.memo.as_deref(), Some("work wallet")); +} + +#[test] +fn update_rename_and_fields_in_single_call() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("alice", "otl_loc_alice", Some("old memo")) + .unwrap(); + tx.commit().unwrap(); + + // Rename + change address + change memo should all be applied by the + // single consolidated UPDATE statement. + let mut tx = db.create_write_tx().unwrap(); + let updated = tx + .address_book_update( + "alice", + Some("alice_v2"), + Some("otl_loc_alice_v2"), + Some("new memo"), + ) + .unwrap(); + tx.commit().unwrap(); + + assert_eq!(updated.name, "alice_v2"); + assert_eq!(updated.address, "otl_loc_alice_v2"); + assert_eq!(updated.memo.as_deref(), Some("new memo")); + + // The row is now only reachable under the new name. + let mut tx = db.create_write_tx().unwrap(); + assert!(tx.address_book_get("alice").is_err()); + let fetched = tx.address_book_get("alice_v2").unwrap(); + assert_eq!(fetched.address, "otl_loc_alice_v2"); + assert_eq!(fetched.memo.as_deref(), Some("new memo")); +} + +#[test] +fn update_with_none_leaves_fields_untouched() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("bob", "otl_loc_bob", Some("keep me")) + .unwrap(); + tx.commit().unwrap(); + + // All fields `None` — only `updated_at` should be bumped. + let mut tx = db.create_write_tx().unwrap(); + let updated = tx.address_book_update("bob", None, None, None).unwrap(); + tx.commit().unwrap(); + + assert_eq!(updated.name, "bob"); + assert_eq!(updated.address, "otl_loc_bob"); + assert_eq!(updated.memo.as_deref(), Some("keep me")); +} + +#[test] +fn update_with_empty_memo_clears_the_memo() { + // Regression test for the UI bug where clearing a memo silently did + // nothing: the walletd client serialises an empty memo string for + // "user cleared the field", and that must actually overwrite the + // previously-stored memo at the SQL layer. + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("carol", "otl_loc_carol", Some("to be cleared")) + .unwrap(); + tx.commit().unwrap(); + + let mut tx = db.create_write_tx().unwrap(); + let updated = tx.address_book_update("carol", None, None, Some("")).unwrap(); + tx.commit().unwrap(); + + assert_eq!( + updated.memo.as_deref(), + Some(""), + "empty memo string must overwrite the stored memo", + ); +} + +#[test] +fn insert_duplicate_name_returns_duplicate_name_variant() { + // The UI relies on the typed `DuplicateName` variant to render a + // user-friendly error without string-matching sqlite's driver text. + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("dave", "otl_loc_dave", None).unwrap(); + tx.commit().unwrap(); + + let mut tx = db.create_write_tx().unwrap(); + let err = tx + .address_book_insert("dave", "otl_loc_dave_second", None) + .unwrap_err(); + assert!( + matches!(err, WalletStorageError::DuplicateName { ref name } if name == "dave"), + "expected DuplicateName {{ name: \"dave\" }}, got {err:?}", + ); +} + +#[test] +fn rename_onto_existing_name_returns_duplicate_name_variant() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("eve", "otl_loc_eve", None).unwrap(); + tx.address_book_insert("frank", "otl_loc_frank", None).unwrap(); + tx.commit().unwrap(); + + // Renaming "frank" -> "eve" must violate the UNIQUE constraint and be + // surfaced as DuplicateName { name: "eve" } so the UI can show a + // message scoped to the attempted new name. + let mut tx = db.create_write_tx().unwrap(); + let err = tx + .address_book_update("frank", Some("eve"), None, None) + .unwrap_err(); + assert!( + matches!(err, WalletStorageError::DuplicateName { ref name } if name == "eve"), + "expected DuplicateName {{ name: \"eve\" }}, got {err:?}", + ); +} + +#[test] +fn update_missing_entry_returns_not_found() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + let err = tx + .address_book_update("ghost", Some("ghost2"), None, None) + .unwrap_err(); + assert!( + matches!(err, WalletStorageError::NotFound { .. }), + "expected NotFound, got {err:?}", + ); +} + +#[test] +fn delete_round_trips() { + let db = open_store(); + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_insert("heidi", "otl_loc_heidi", None).unwrap(); + tx.commit().unwrap(); + + let mut tx = db.create_write_tx().unwrap(); + tx.address_book_delete("heidi").unwrap(); + tx.commit().unwrap(); + + let mut tx = db.create_write_tx().unwrap(); + assert!(tx.address_book_get("heidi").is_err()); +}