diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 17fcac5e..f8294778 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -43,4 +43,9 @@ SNAG_RULE_ID="your-snag-rule-id-here" # x402 gasless USDC deposit (optional) FEATURE_X402_DEPOSIT=false -X402_FACILITATOR_URL=https://facilitator.payai.network \ No newline at end of file +X402_FACILITATOR_URL=https://facilitator.payai.network + +# Stealth payments via Umbra (optional, Base mainnet only) +FEATURE_STEALTH=false +STEALTH_RELAYER_PRIVATE_KEY= +STEALTH_RPC_URL=https://mainnet.base.org \ No newline at end of file diff --git a/packages/backend/prisma/migrations/20260511000000_add_send_privately_to_batch_items/migration.sql b/packages/backend/prisma/migrations/20260511000000_add_send_privately_to_batch_items/migration.sql new file mode 100644 index 00000000..39af0ca5 --- /dev/null +++ b/packages/backend/prisma/migrations/20260511000000_add_send_privately_to_batch_items/migration.sql @@ -0,0 +1,3 @@ +-- Add stealth (send privately) flag to batch_items. +ALTER TABLE "batch_items" +ADD COLUMN "send_privately" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 4074d43c..fe037409 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -126,16 +126,17 @@ model Vote { } model BatchItem { - id String @id @default(cuid()) - userId String @map("user_id") - contactId String? @map("contact_id") - recipient String - amount String - tokenAddress String? @map("token_address") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - contact Contact? @relation(fields: [contactId], references: [id]) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String @map("user_id") + contactId String? @map("contact_id") + recipient String + amount String + tokenAddress String? @map("token_address") + sendPrivately Boolean @default(false) @map("send_privately") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + contact Contact? @relation(fields: [contactId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("batch_items") diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index daa47962..2368378a 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -24,8 +24,10 @@ import { AdminModule } from './admin/admin.module'; import { BalanceAlertModule } from './balance-alert/balance-alert.module'; import { ScheduleModule } from '@nestjs/schedule'; import { X402Module } from './x402/x402.module'; +import { StealthModule } from './stealth/stealth.module'; const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true'; +const featureStealth = process.env.FEATURE_STEALTH === 'true'; @Module({ imports: [ @@ -53,6 +55,7 @@ const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true'; BalanceAlertModule, ScheduleModule.forRoot(), ...(featureX402 ? [X402Module] : []), + ...(featureStealth ? [StealthModule] : []), ], }) export class AppModule implements NestModule { diff --git a/packages/backend/src/batch-item/batch-item.service.ts b/packages/backend/src/batch-item/batch-item.service.ts index 349407dc..a761164d 100644 --- a/packages/backend/src/batch-item/batch-item.service.ts +++ b/packages/backend/src/batch-item/batch-item.service.ts @@ -28,6 +28,7 @@ export class BatchItemService { amount: dto.amount, tokenAddress: dto.tokenAddress, contactId: dto.contactId, + sendPrivately: dto.sendPrivately ?? false, }, include: { contact: true, diff --git a/packages/backend/src/config/config.keys.ts b/packages/backend/src/config/config.keys.ts index bd28b6ac..6e0b3c63 100644 --- a/packages/backend/src/config/config.keys.ts +++ b/packages/backend/src/config/config.keys.ts @@ -33,4 +33,9 @@ export const CONFIG_KEYS = { X402_ENABLED: 'x402.enabled', X402_FACILITATOR_URL: 'x402.facilitatorUrl', X402_FACILITATOR_BEARER_TOKEN: 'x402.facilitatorBearerToken', + + // Stealth payments (Umbra) + STEALTH_ENABLED: 'stealth.enabled', + STEALTH_RELAYER_PRIVATE_KEY: 'stealth.relayerPrivateKey', + STEALTH_RPC_URL: 'stealth.rpcUrl', } as const; diff --git a/packages/backend/src/config/config.module.ts b/packages/backend/src/config/config.module.ts index 7f3af77d..7d3c297d 100644 --- a/packages/backend/src/config/config.module.ts +++ b/packages/backend/src/config/config.module.ts @@ -7,6 +7,7 @@ import relayerConfig from './relayer.config'; import telegramConfig from './telegram.config'; import snagConfig from './snag.config'; import x402Config from './x402.config'; +import stealthConfig from './stealth.config'; import { validationSchema } from './env.validation'; @Module({ @@ -21,6 +22,7 @@ import { validationSchema } from './env.validation'; telegramConfig, snagConfig, x402Config, + stealthConfig, ], envFilePath: ['.env.local', '.env'], cache: true, diff --git a/packages/backend/src/config/env.validation.ts b/packages/backend/src/config/env.validation.ts index d2e45408..78371ed4 100644 --- a/packages/backend/src/config/env.validation.ts +++ b/packages/backend/src/config/env.validation.ts @@ -53,4 +53,22 @@ export const validationSchema = Joi.object({ otherwise: Joi.string().uri().optional(), }), X402_FACILITATOR_BEARER_TOKEN: Joi.string().optional().allow(''), + + // Stealth payments (optional; module loads only when FEATURE_STEALTH=true) + FEATURE_STEALTH: Joi.string().valid('true', 'false').default('false'), + STEALTH_RELAYER_PRIVATE_KEY: Joi.alternatives().conditional( + 'FEATURE_STEALTH', + { + is: 'true', + then: Joi.string() + .pattern(/^0x[a-fA-F0-9]{64}$/) + .required(), + otherwise: Joi.string().optional().allow(''), + }, + ), + STEALTH_RPC_URL: Joi.alternatives().conditional('FEATURE_STEALTH', { + is: 'true', + then: Joi.string().uri().required(), + otherwise: Joi.string().uri().optional(), + }), }); diff --git a/packages/backend/src/config/stealth.config.ts b/packages/backend/src/config/stealth.config.ts new file mode 100644 index 00000000..2336f518 --- /dev/null +++ b/packages/backend/src/config/stealth.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('stealth', () => ({ + enabled: process.env.FEATURE_STEALTH === 'true', + relayerPrivateKey: process.env.STEALTH_RELAYER_PRIVATE_KEY ?? '', + rpcUrl: process.env.STEALTH_RPC_URL ?? 'https://mainnet.base.org', +})); diff --git a/packages/backend/src/stealth/stealth.constants.ts b/packages/backend/src/stealth/stealth.constants.ts new file mode 100644 index 00000000..2a68a22a --- /dev/null +++ b/packages/backend/src/stealth/stealth.constants.ts @@ -0,0 +1,35 @@ +// Base mainnet is the only chain where Umbra is deployed and we operate. +export const STEALTH_CHAIN_ID = 8453; + +// ABI fragments — we keep these minimal so we don't ship the full Umbra ABI +// just for two functions. +export const STEALTH_KEY_REGISTRY_ABI = [ + { + name: 'stealthKeys', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'registrant', type: 'address' }], + outputs: [ + { name: 'spendingPubKeyPrefix', type: 'uint256' }, + { name: 'spendingPubKey', type: 'uint256' }, + { name: 'viewingPubKeyPrefix', type: 'uint256' }, + { name: 'viewingPubKey', type: 'uint256' }, + ], + }, + { + name: 'setStealthKeysOnBehalf', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'registrant', type: 'address' }, + { name: 'spendingPubKeyPrefix', type: 'uint256' }, + { name: 'spendingPubKey', type: 'uint256' }, + { name: 'viewingPubKeyPrefix', type: 'uint256' }, + { name: 'viewingPubKey', type: 'uint256' }, + { name: 'v', type: 'uint8' }, + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + ], + outputs: [], + }, +] as const; diff --git a/packages/backend/src/stealth/stealth.controller.ts b/packages/backend/src/stealth/stealth.controller.ts new file mode 100644 index 00000000..61f3988d --- /dev/null +++ b/packages/backend/src/stealth/stealth.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +import { + RegisterStealthKeysDto, + type RegisterStealthKeysResponse, + type StealthRegistrationStatusResponse, +} from '@polypay/shared'; +import { StealthService } from './stealth.service'; + +@Controller('stealth') +@UseGuards(ThrottlerGuard) +export class StealthController { + constructor(private readonly stealthService: StealthService) {} + + // Heavier limit on register because it triggers an on-chain tx with our gas. + @Throttle({ default: { ttl: 60_000, limit: 5 } }) + @Post('register') + async register( + @Body() dto: RegisterStealthKeysDto, + ): Promise { + return this.stealthService.register(dto); + } + + @Throttle({ default: { ttl: 60_000, limit: 60 } }) + @Get('status/:walletAddress') + async status( + @Param('walletAddress') walletAddress: string, + ): Promise { + return this.stealthService.getStatus(walletAddress); + } +} diff --git a/packages/backend/src/stealth/stealth.module.ts b/packages/backend/src/stealth/stealth.module.ts new file mode 100644 index 00000000..cabd84e6 --- /dev/null +++ b/packages/backend/src/stealth/stealth.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { StealthController } from './stealth.controller'; +import { StealthService } from './stealth.service'; + +@Module({ + controllers: [StealthController], + providers: [StealthService], +}) +export class StealthModule {} diff --git a/packages/backend/src/stealth/stealth.service.ts b/packages/backend/src/stealth/stealth.service.ts new file mode 100644 index 00000000..bfe7dc16 --- /dev/null +++ b/packages/backend/src/stealth/stealth.service.ts @@ -0,0 +1,200 @@ +import { + Injectable, + Logger, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + createPublicClient, + createWalletClient, + http, + hexToSignature, + type Address, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base } from 'viem/chains'; +import { + RegisterStealthKeysDto, + type RegisterStealthKeysResponse, + type StealthRegistrationStatusResponse, + getUmbraAddresses, +} from '@polypay/shared'; +import { CONFIG_KEYS } from '@/config/config.keys'; +import { + STEALTH_CHAIN_ID, + STEALTH_KEY_REGISTRY_ABI, +} from './stealth.constants'; + +@Injectable() +export class StealthService { + private readonly logger = new Logger(StealthService.name); + // viem's generic types over the chain object explode the TS instantiation + // depth when stored on a class field. We only use vanilla methods so the + // loose type is fine here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly publicClient: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly walletClient: any; + private readonly registryAddress: Address; + private readonly relayerAddress: Address; + + // In-memory status cache. Registry rarely changes, so a short TTL is enough + // to absorb burst lookups during a batch propose. + private readonly statusCache = new Map< + string, + { value: StealthRegistrationStatusResponse; expiresAt: number } + >(); + private readonly cacheTtlMs = 60_000; + + constructor(private readonly config: ConfigService) { + const rpcUrl = this.config.getOrThrow(CONFIG_KEYS.STEALTH_RPC_URL); + const relayerKey = this.config.getOrThrow( + CONFIG_KEYS.STEALTH_RELAYER_PRIVATE_KEY, + ); + + const account = privateKeyToAccount(relayerKey as `0x${string}`); + this.relayerAddress = account.address; + + // Cast away Base's strict L2 chain type — we only use vanilla read/write + // methods that work fine regardless of the chain's optimism stack types. + this.publicClient = createPublicClient({ + chain: base as never, + transport: http(rpcUrl), + }); + this.walletClient = createWalletClient({ + chain: base as never, + transport: http(rpcUrl), + account, + }); + + const { registry } = getUmbraAddresses(STEALTH_CHAIN_ID); + this.registryAddress = registry as Address; + + this.logger.log( + `StealthService ready. Relayer ${this.relayerAddress}, registry ${this.registryAddress}`, + ); + } + + async getStatus( + walletAddress: string, + ): Promise { + const normalized = this.normalizeAddress(walletAddress); + const cached = this.statusCache.get(normalized); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const result = (await this.publicClient.readContract({ + address: this.registryAddress, + abi: STEALTH_KEY_REGISTRY_ABI, + functionName: 'stealthKeys', + args: [normalized as Address], + })) as readonly [bigint, bigint, bigint, bigint]; + + const [ + spendingPubKeyPrefix, + spendingPubKey, + viewingPubKeyPrefix, + viewingPubKey, + ] = result; + + const registered = spendingPubKey !== 0n && viewingPubKey !== 0n; + const response: StealthRegistrationStatusResponse = registered + ? { + walletAddress: normalized, + registered, + spendingPubKeyPrefix: Number(spendingPubKeyPrefix), + spendingPubKey: this.toHex32(spendingPubKey), + viewingPubKeyPrefix: Number(viewingPubKeyPrefix), + viewingPubKey: this.toHex32(viewingPubKey), + } + : { walletAddress: normalized, registered }; + + this.statusCache.set(normalized, { + value: response, + expiresAt: Date.now() + this.cacheTtlMs, + }); + return response; + } + + async register( + dto: RegisterStealthKeysDto, + ): Promise { + const existing = await this.getStatus(dto.walletAddress); + if ( + existing.registered && + existing.spendingPubKey?.toLowerCase() === + dto.spendingPubKey.toLowerCase() && + existing.viewingPubKey?.toLowerCase() === dto.viewingPubKey.toLowerCase() + ) { + throw new BadRequestException( + 'Wallet already registered with these keys', + ); + } + + const { v, r, s } = this.splitSignature(dto.signature); + + try { + const txHash = await this.walletClient.writeContract({ + address: this.registryAddress, + abi: STEALTH_KEY_REGISTRY_ABI, + functionName: 'setStealthKeysOnBehalf', + args: [ + this.normalizeAddress(dto.walletAddress) as Address, + BigInt(dto.spendingPubKeyPrefix), + BigInt(dto.spendingPubKey), + BigInt(dto.viewingPubKeyPrefix), + BigInt(dto.viewingPubKey), + v, + r, + s, + ], + chain: base as never, + account: this.walletClient.account!, + }); + + // Invalidate cache so the next status read reflects the new registration. + this.statusCache.delete(this.normalizeAddress(dto.walletAddress)); + + this.logger.log( + `Submitted setStealthKeysOnBehalf for ${dto.walletAddress} tx=${txHash}`, + ); + + return { + txHash, + registryAddress: this.registryAddress, + chainId: STEALTH_CHAIN_ID, + }; + } catch (err) { + this.logger.error( + `setStealthKeysOnBehalf failed for ${dto.walletAddress}: ${(err as Error).message}`, + ); + throw new InternalServerErrorException( + 'Failed to submit stealth registration on chain', + ); + } + } + + private normalizeAddress(value: string): string { + return value.toLowerCase(); + } + + private toHex32(value: bigint): string { + return `0x${value.toString(16).padStart(64, '0')}`; + } + + private splitSignature(signature: string): { + v: number; + r: `0x${string}`; + s: `0x${string}`; + } { + const split = hexToSignature(signature as `0x${string}`); + // viem returns v as bigint; the registry expects uint8. + const v = Number(split.v); + if (v !== 27 && v !== 28) { + throw new BadRequestException('Invalid signature v value'); + } + return { v, r: split.r, s: split.s }; + } +} diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 8e1d93db..14335310 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -3,3 +3,6 @@ NEXT_PUBLIC_NETWORK="testnet" # x402 gasless USDC deposit (optional) NEXT_PUBLIC_FEATURE_X402_DEPOSIT=false + +# Stealth payments via Umbra (optional, Base mainnet only) +NEXT_PUBLIC_FEATURE_STEALTH=false diff --git a/packages/nextjs/app/receive/page.tsx b/packages/nextjs/app/receive/page.tsx new file mode 100644 index 00000000..00dc080a --- /dev/null +++ b/packages/nextjs/app/receive/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import { notFound } from "next/navigation"; +import { isStealthSupportedChain } from "@polypay/shared"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useAccount, useChainId } from "wagmi"; +import { STEALTH_ENABLED } from "~~/constants"; +import { useStealthStatus } from "~~/hooks/api/useStealthStatus"; +import { useStealthRegistration } from "~~/hooks/app/useStealthRegistration"; +import { formatErrorMessage } from "~~/utils/formatError"; +import { notification } from "~~/utils/scaffold-eth"; + +const BASE_MAINNET = 8453; +const DEFAULT_RPC_URL = "https://mainnet.base.org"; + +export default function ReceivePage() { + const { address, isConnected, connector } = useAccount(); + const chainId = useChainId(); + const stealthStatus = useStealthStatus(address ?? null); + const { register, step, isLoading } = useStealthRegistration(); + const [copied, setCopied] = useState(false); + + // When the feature flag is off, treat the route as if it didn't exist. We + // call all hooks above unconditionally to satisfy rules-of-hooks. + if (!STEALTH_ENABLED) { + notFound(); + } + + const wrongChain = isConnected && !isStealthSupportedChain(chainId); + const alreadyRegistered = stealthStatus.data?.registered === true; + + const handleRegister = async () => { + if (!connector) { + notification.error("Wallet not ready"); + return; + } + try { + const provider = await connector.getProvider(); + await register({ + chainId: BASE_MAINNET, + rpcUrl: DEFAULT_RPC_URL, + injectedProvider: provider, + }); + notification.success("Stealth keys registered on Base"); + } catch (err) { + notification.error(formatErrorMessage(err, "Registration failed")); + } + }; + + const handleCopyAddress = async () => { + if (!address) return; + await navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+

Receive private payments

+

+ Set up a one-time receiving identity. Senders using PolyPay can pay you privately via the Umbra stealth + address protocol on Base. +

+
+ + {!isConnected && ( +
+

Connect the wallet you want to receive into.

+ +
+ )} + + {isConnected && wrongChain && ( +
+ Switch to Base mainnet to register. Stealth payments are not available on other networks. +
+ )} + + {isConnected && !wrongChain && stealthStatus.isLoading && ( +
Checking registration status…
+ )} + + {isConnected && !wrongChain && alreadyRegistered && ( +
+
+ You are already set up to receive private payments. +
+
+ Share this wallet address with senders: +
+ {address} + +
+
+
+ To check and withdraw incoming payments: +
    +
  1. + Open{" "} + + app.umbra.cash + +
  2. +
  3. Connect this same wallet and sign to scan your inbox
  4. +
  5. Withdraw — Umbra's relayer pays gas and deducts a small fee from the token
  6. +
+
+
+ )} + + {isConnected && !wrongChain && stealthStatus.isFetched && !alreadyRegistered && ( +
+
+

You will be asked to sign two messages:

+
    +
  1. A canonical Umbra message to derive your stealth keys.
  2. +
  3. + An EIP-712 authorization so PolyPay can submit the registration on chain (no gas required from you). +
  4. +
+

+ Tip: the same wallet seed will always derive the same keys. Back up your seed phrase — losing it means + losing access to past stealth payments. +

+
+ +
+ )} +
+
+ ); +} diff --git a/packages/nextjs/components/Batch/BatchContainer.tsx b/packages/nextjs/components/Batch/BatchContainer.tsx index febcefca..ed901a4a 100644 --- a/packages/nextjs/components/Batch/BatchContainer.tsx +++ b/packages/nextjs/components/Batch/BatchContainer.tsx @@ -70,7 +70,16 @@ function BatchTransactions({ onSelectAll: () => void; onSelectItem: (id: string) => void; onRemove: (id: string) => void; - onEdit: (id: string, data: { recipient: string; amount: string; token: ResolvedToken; contactId?: string }) => void; + onEdit: ( + id: string, + data: { + recipient: string; + amount: string; + token: ResolvedToken; + contactId?: string; + sendPrivately?: boolean; + }, + ) => void; isLoading?: boolean; isRemoving?: boolean; accountId: string | null; @@ -355,7 +364,16 @@ export default function BatchContainer() { ); const handleEdit = useCallback( - async (id: string, data: { recipient: string; amount: string; token: ResolvedToken; contactId?: string }) => { + async ( + id: string, + data: { + recipient: string; + amount: string; + token: ResolvedToken; + contactId?: string; + sendPrivately?: boolean; + }, + ) => { try { const amountInSmallestUnit = parseTokenAmount(data.amount, data.token.decimals); @@ -366,6 +384,7 @@ export default function BatchContainer() { amount: amountInSmallestUnit, tokenAddress: data.token.address, contactId: data.contactId, + sendPrivately: data.sendPrivately, }, }); diff --git a/packages/nextjs/components/NewAccount/SuccessScreen.tsx b/packages/nextjs/components/NewAccount/SuccessScreen.tsx index 6fe3526f..e6c69bfa 100644 --- a/packages/nextjs/components/NewAccount/SuccessScreen.tsx +++ b/packages/nextjs/components/NewAccount/SuccessScreen.tsx @@ -24,6 +24,7 @@ const SuccessScreen: React.FC = ({ className, createdAccount const router = useAppRouter(); const [isReceiveModalOpen, setIsReceiveModalOpen] = useState(false); const [fundAddress, setFundAddress] = useState(""); + const [fundChainId, setFundChainId] = useState(undefined); const { currentAccount } = useAccountStore(); const { openManageAccountsWithExpand } = useSidebarStore(); @@ -35,8 +36,9 @@ const SuccessScreen: React.FC = ({ className, createdAccount router.goToDashboard(); }; - const handleFund = (address: string) => { + const handleFund = (address: string, chainId?: number) => { setFundAddress(address); + setFundChainId(chainId); setIsReceiveModalOpen(true); }; @@ -108,7 +110,7 @@ const SuccessScreen: React.FC = ({ className, createdAccount {/* Fund button */} + {/* Spacer to balance back button so title stays centered */} +
{/* Content */} diff --git a/packages/nextjs/components/popovers/EditBatchPopover.tsx b/packages/nextjs/components/popovers/EditBatchPopover.tsx index 4873f042..0ee6c2a9 100644 --- a/packages/nextjs/components/popovers/EditBatchPopover.tsx +++ b/packages/nextjs/components/popovers/EditBatchPopover.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import { TokenPillPopover } from "./TokenPillPopover"; import { BatchItem, ResolvedToken, ZERO_ADDRESS, getTokenByAddress } from "@polypay/shared"; import { formatEther, formatUnits } from "viem"; +import { StealthToggle } from "~~/components/Transfer/StealthToggle"; import { ContactPicker } from "~~/components/contact-book/ContactPicker"; import { useContacts } from "~~/hooks"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; @@ -18,7 +19,13 @@ interface EditBatchPopoverProps { item: BatchItem; isOpen: boolean; onClose: () => void; - onSave: (data: { recipient: string; amount: string; token: ResolvedToken; contactId?: string }) => void; + onSave: (data: { + recipient: string; + amount: string; + token: ResolvedToken; + contactId?: string; + sendPrivately?: boolean; + }) => void; triggerRef: React.RefObject; } @@ -53,6 +60,7 @@ export default function EditBatchPopover({ item, isOpen, onClose, onSave, trigge tokenAddress: item.tokenAddress || ZERO_ADDRESS, contactId: item.contact?.id || undefined, contactName: item.contact?.name || undefined, + sendPrivately: item.sendPrivately ?? false, }, }); @@ -122,6 +130,7 @@ export default function EditBatchPopover({ item, isOpen, onClose, onSave, trigge amount: data.amount, token: token, contactId: data.contactId || undefined, + sendPrivately: data.sendPrivately, }); onClose(); }; @@ -239,6 +248,14 @@ export default function EditBatchPopover({ item, isOpen, onClose, onSave, trigge )} + form.setValue("sendPrivately", next, { shouldValidate: false })} + chainId={selectedAccount?.chainId} + tokenAddress={watchedTokenAddress || ZERO_ADDRESS} + recipientAddress={watchedRecipient || undefined} + /> +