From 11902181affd1bf3bb26a829d7ebe20fbe9a612e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:25:33 +0000 Subject: [PATCH 01/17] Initial plan From ad63afb56145efd18b39254c800b784e4173c9b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:40:48 +0000 Subject: [PATCH 02/17] feat: multi-buyer support in AI Credits widget Co-authored-by: blueogin <43612769+blueogin@users.noreply.github.com> --- .../AiCreditsWidgetQA.stories.tsx | 18 + .../helpers/aiCreditsWidgetStories.tsx | 96 +++++ .../ai-credits-widget/src/AiCreditsWidget.tsx | 8 +- packages/ai-credits-widget/src/adapter.ts | 370 +++++++++++++++++- .../src/buyerKeyDerivation.ts | 10 +- .../src/components/history/HistoryTab.tsx | 104 ++++- .../components/manage/BuyerOperatorCard.tsx | 264 ++++++++++++- packages/ai-credits-widget/src/index.ts | 3 + .../ai-credits-widget/src/payerSession.ts | 127 +++++- .../src/useAiCreditsHistory.ts | 32 +- .../src/widgetRuntimeContract.ts | 40 ++ .../widgets/ai-credits-widget/states.spec.ts | 75 ++++ 12 files changed, 1105 insertions(+), 42 deletions(-) diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx index c562c31b..4ad5bca8 100644 --- a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx @@ -16,6 +16,9 @@ import { BackendUnavailableStory, UnsupportedChainStory, AppKitConnectWalletStory, + MultiBuyerManageStory, + AddressOnlyBuyerStory, + MultiBuyerHistoryStory, } from '../helpers/aiCreditsWidgetStories' const meta: Meta = { @@ -86,6 +89,21 @@ export const UnsupportedChain: Story = { render: () => , } +/** Multi-buyer manage tab: buyer selector and private-key reveal. */ +export const MultiBuyerManage: Story = { + render: () => , +} + +/** Address-only buyer: sign-required actions are disabled. */ +export const AddressOnlyBuyer: Story = { + render: () => , +} + +/** History tab with buyer filter dropdown. */ +export const MultiBuyerHistory: Story = { + render: () => , +} + export const AppKitConnectWallet: Story = { render: () => , play: async ({ canvasElement }) => { diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index de260e3a..58eecfd3 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -44,6 +44,8 @@ function createMockState( streamBonusPercent: 20, error: null, activeTab: 'buy', + buyers: [], + activeBuyerAddress: null, } return { ...base, ...overrides } } @@ -58,6 +60,11 @@ function createAdapterFactory( connect: async () => {}, switchChain: async () => {}, generateBuyerKey: async () => {}, + createBuyer: async () => {}, + selectBuyer: () => {}, + importBuyerFromPrivateKey: async () => {}, + selectBuyerByAddress: () => {}, + applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, buildQuote: async (depositG, streamG) => ({ @@ -398,3 +405,92 @@ export function InjectedWalletStory() { ) } + +// --------------------------------------------------------------------------- +// Multi-buyer fixture stories +// --------------------------------------------------------------------------- + +const BUYER_A = { + address: '0xfc128652c9b397a1f89A9EC84E798B869B0E4c7a' as const, + privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001' as const, + label: 'Buyer 1', +} + +const BUYER_B = { + address: '0xAbcDef1234567890AbcDef1234567890AbcDef12' as const, + privateKey: '0x0000000000000000000000000000000000000000000000000000000000000002' as const, + label: 'Buyer 2', +} + +const BUYER_WATCH = { + address: '0x1111111111111111111111111111111111111111' as const, + label: 'Watch 0x1111…1111', +} + +/** Multi-buyer manage tab: two derived buyers + one address-only watcher. */ +export function MultiBuyerManageStory() { + return ( + + ) +} + +/** Address-only buyer selected: sign-required actions should be disabled. */ +export function AddressOnlyBuyerStory() { + return ( + + ) +} + +/** History tab with multi-buyer filter options available. */ +export function MultiBuyerHistoryStory() { + return ( + + ) +} diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index 81c75c2e..191a62aa 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -308,6 +308,8 @@ function AiCreditsInner({ const history = useAiCreditsHistory({ address: state.address, backendUrl, + // Default to the active buyer so users see filtered history immediately + defaultBuyerFilter: state.activeBuyerAddress ?? 'all', }) const handlePay = useCallback( @@ -379,7 +381,11 @@ function AiCreditsInner({ {state.activeTab === 'manage' ? ( ) : state.activeTab === 'history' ? ( - + ({ address: b.address, label: b.label }))} + /> ) : ( buyPanel )} diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 0c2283f0..92925500 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -37,7 +37,9 @@ import { patchPayerSessionFields, patchPayerSession, readPayerSession, + addBuyerToSession, } from './payerSession' +import type { BuyerRecord } from './payerSession' import { executeCeloPayment, G_TOKEN_CELO_ADDRESS, isStreamAmountChanged } from './celoPayment' import { startGoodIdVerification, isUserRejectedWalletRequest } from './goodIdVerification' import { mapPaymentError } from './paymentErrors' @@ -95,6 +97,8 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { streamBonusPercent: DEFAULT_DISCOUNT_CONFIG.streamBonusPercent, error: null, activeTab: 'buy', + buyers: [], + activeBuyerAddress: null, } const WALLET_LOADING_STATE: Partial = { @@ -285,7 +289,12 @@ function mergeSessionFields( sessionPatch: ReturnType, accountPatch: Partial, accountSwitched: boolean, -): Partial> { +): Partial< + Pick< + AiCreditsWidgetAdapterState, + 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' | 'buyers' | 'activeBuyerAddress' + > +> { const buyerPubKey = sessionPatch.buyerPubKey ?? accountPatch.buyerPubKey ?? @@ -294,11 +303,17 @@ function mergeSessionFields( const operatorConsented = accountSwitched ? (sessionPatch.operatorConsented ?? accountPatch.operatorConsented ?? false) : (accountPatch.operatorConsented ?? sessionPatch.operatorConsented ?? prev.operatorConsented) + const buyers = accountSwitched ? sessionPatch.buyers : (sessionPatch.buyers.length > 0 ? sessionPatch.buyers : prev.buyers) + const activeBuyerAddress = accountSwitched + ? (sessionPatch.activeBuyerAddress ?? null) + : (sessionPatch.activeBuyerAddress ?? prev.activeBuyerAddress) return { buyerPubKey, buyerPrvKey, operatorConsented, + buyers, + activeBuyerAddress, } } @@ -307,11 +322,21 @@ function syncOperatorConsentSession(address: string, operatorConsented: boolean patchPayerSession(address, { operatorConsented }) } +/** + * Ensures a buyer derived from the backend account view is reflected in the session. + * Only adds the buyer when no session buyers exist yet (first-time sync). + */ function syncBuyerPubKeySession(address: string, buyerPubKey: string | null | undefined): void { if (!buyerPubKey) return const existing = readPayerSession(address) - if (existing?.buyerPubKey) return - patchPayerSession(address, { buyerPubKey }) + if (existing?.buyers && existing.buyers.length > 0) return + // Persist as a derived buyer at index 0 (legacy-compatible) + addBuyerToSession(address, { + address: buyerPubKey, + type: 'derived', + derivationIndex: 0, + label: 'Buyer 1', + }) } export interface UseAiCreditsAdapterOptions { @@ -555,7 +580,9 @@ export function useAiCreditsAdapter({ try { const payerAddress = address as Address - const message = buildBuyerKeyMessage(payerAddress) + // Use index 0 for the first/default buyer to preserve backward compatibility + const nextIndex = 0 + const message = buildBuyerKeyMessage(payerAddress, nextIndex) const walletClient = createWalletClient({ account: payerAddress, chain: CELO_CHAIN, @@ -566,17 +593,98 @@ export function useAiCreditsAdapter({ message, }) const privateKey = deriveBuyerPrivateKeyFromSignature(signature) - const account = privateKeyToAccount(privateKey) + const buyerAccount = privateKeyToAccount(privateKey) + const label = 'Buyer 1' + + const buyerRecord: BuyerRecord = { + address: buyerAccount.address, + privateKey, + type: 'derived', + derivationIndex: nextIndex, + label, + } + addBuyerToSession(payerAddress, buyerRecord) + + const updatedSession = patchPayerSessionFields(payerAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: buyerAccount.address, + buyerPrvKey: privateKey, + buyers: updatedSession.buyers, + activeBuyerAddress: buyerAccount.address, + error: null, + ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), + }), + ) + } catch (err: unknown) { + setState((prev) => + withDerivedStatus( + prev, + { + error: err instanceof Error ? err.message : 'Buyer key generation was rejected', + }, + true, + ), + ) + } + }, [address]) + + /** + * Creates a new derived buyer at the next available derivation index. + * Prompts the connected wallet to sign a unique message for each buyer. + */ + const handleCreateBuyer = useCallback(async () => { + if (!address || !providerRef.current) { + setState((prev) => + withDerivedStatus( + prev, + { error: 'Connect your wallet before creating a buyer' }, + true, + ), + ) + return + } + + try { + const payerAddress = address as Address + const existingSession = patchPayerSessionFields(payerAddress) + // Next index = highest derivation index + 1, or 0 if none exist + const nextIndex = existingSession.buyers + .filter((b) => b.type === 'derived' && b.derivationIndex !== undefined) + .reduce((max, b) => Math.max(max, b.derivationIndex ?? 0), -1) + 1 - patchPayerSession(payerAddress, { - buyerPubKey: account.address, - buyerPrvKey: privateKey, + const message = buildBuyerKeyMessage(payerAddress, nextIndex) + const walletClient = createWalletClient({ + account: payerAddress, + chain: CELO_CHAIN, + transport: custom(providerRef.current), }) + const signature = await walletClient.signMessage({ + account: payerAddress, + message, + }) + const privateKey = deriveBuyerPrivateKeyFromSignature(signature) + const buyerAccount = privateKeyToAccount(privateKey) + const label = `Buyer ${nextIndex + 1}` + + const buyerRecord: BuyerRecord = { + address: buyerAccount.address, + privateKey, + type: 'derived', + derivationIndex: nextIndex, + label, + } + addBuyerToSession(payerAddress, buyerRecord) + const updatedSession = patchPayerSessionFields(payerAddress) setState((prev) => mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: account.address, + buyerPubKey: buyerAccount.address, buyerPrvKey: privateKey, + buyers: updatedSession.buyers, + activeBuyerAddress: buyerAccount.address, + // Reset operator consent since this is a new buyer + operatorConsented: false, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), }), @@ -586,7 +694,7 @@ export function useAiCreditsAdapter({ withDerivedStatus( prev, { - error: err instanceof Error ? err.message : 'Buyer key generation was rejected', + error: err instanceof Error ? err.message : 'Buyer creation was rejected', }, true, ), @@ -594,6 +702,226 @@ export function useAiCreditsAdapter({ } }, [address]) + /** + * Switches the active buyer to an existing one in the session. + * Resets operator consent so it is re-verified for the newly selected buyer. + */ + const handleSelectBuyer = useCallback( + (buyerAddress: string) => { + if (!address) return + const session = patchPayerSessionFields(address) + const target = session.buyers.find( + (b) => b.address.toLowerCase() === buyerAddress.toLowerCase(), + ) + if (!target) return + + patchPayerSession(address, { activeBuyerAddress: target.address, operatorConsented: false }) + + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: target.address, + buyerPrvKey: target.privateKey ?? null, + buyers: session.buyers, + activeBuyerAddress: target.address, + // Reset consent and chain-loaded data for the newly selected buyer + operatorConsented: false, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + monthlyStreamG: null, + error: null, + }), + ) + }, + [address], + ) + + /** + * Imports a buyer identity from a hex-encoded private key string. + * Validates the key format strictly before accepting. + */ + const handleImportBuyerFromPrivateKey = useCallback( + async (rawPrivateKey: string) => { + if (!address) { + setState((prev) => + withDerivedStatus(prev, { error: 'Connect your wallet before importing a buyer key' }, true), + ) + return + } + + const trimmed = rawPrivateKey.trim() + const normalized = trimmed.startsWith('0x') ? trimmed : `0x${trimmed}` + if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) { + setState((prev) => + withDerivedStatus( + prev, + { error: 'Invalid private key format — expected 0x followed by 64 hex characters' }, + true, + ), + ) + return + } + + try { + const privateKey = normalized as `0x${string}` + const buyerAccount = privateKeyToAccount(privateKey) + const existingSession = patchPayerSessionFields(address) + const label = `Imported ${existingSession.buyers.filter((b) => b.type === 'imported').length + 1}` + + const buyerRecord: BuyerRecord = { + address: buyerAccount.address, + privateKey, + type: 'imported', + label, + } + addBuyerToSession(address, buyerRecord) + + const updatedSession = patchPayerSessionFields(address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: buyerAccount.address, + buyerPrvKey: privateKey, + buyers: updatedSession.buyers, + activeBuyerAddress: buyerAccount.address, + operatorConsented: false, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + monthlyStreamG: null, + error: null, + ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), + }), + ) + } catch { + setState((prev) => + withDerivedStatus(prev, { error: 'Could not derive an account from the provided private key' }, true), + ) + } + }, + [address], + ) + + /** + * Registers a buyer address without a private key (view / consent-pairing mode). + * Actions that require signing will be disabled for this buyer in the UI. + */ + const handleSelectBuyerByAddress = useCallback( + (buyerAddress: string) => { + if (!address) { + setState((prev) => + withDerivedStatus(prev, { error: 'Connect your wallet before selecting a buyer address' }, true), + ) + return + } + + const trimmed = buyerAddress.trim() + if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) { + setState((prev) => + withDerivedStatus( + prev, + { error: 'Invalid buyer address format — expected 0x followed by 40 hex characters' }, + true, + ), + ) + return + } + + const existingSession = patchPayerSessionFields(address) + const existingBuyer = existingSession.buyers.find( + (b) => b.address.toLowerCase() === trimmed.toLowerCase(), + ) + + const buyerRecord: BuyerRecord = existingBuyer ?? { + address: trimmed, + type: 'address-only', + label: `Watch ${trimmed.slice(0, 6)}…${trimmed.slice(-4)}`, + } + + addBuyerToSession(address, buyerRecord) + + const updatedSession = patchPayerSessionFields(address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: trimmed, + buyerPrvKey: existingBuyer?.privateKey ?? null, + buyers: updatedSession.buyers, + activeBuyerAddress: trimmed, + operatorConsented: false, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + monthlyStreamG: null, + error: null, + }), + ) + }, + [address], + ) + + /** + * Applies a deep-link buyer assignment from URL GET parameters. + * Validates that the provided address is a valid EVM address and that + * the accompanying signature is a non-empty hex string before accepting. + * The buyer is registered as an address-only identity since we do not + * receive the private key through the URL. + */ + const handleApplyDeepLinkBuyer = useCallback( + async (buyerAddress: string, buyerSignature: string) => { + if (!address) { + setState((prev) => + withDerivedStatus(prev, { error: 'Connect your wallet before applying a deep-link buyer' }, true), + ) + return + } + + const trimmedAddress = buyerAddress.trim() + const trimmedSignature = buyerSignature.trim() + + if (!/^0x[0-9a-fA-F]{40}$/.test(trimmedAddress)) { + setState((prev) => + withDerivedStatus(prev, { error: 'Deep-link buyer address is invalid' }, true), + ) + return + } + + if (!/^0x[0-9a-fA-F]{2,}$/.test(trimmedSignature)) { + setState((prev) => + withDerivedStatus(prev, { error: 'Deep-link buyer signature is invalid' }, true), + ) + return + } + + const buyerRecord: BuyerRecord = { + address: trimmedAddress, + type: 'address-only', + label: `Partner ${trimmedAddress.slice(0, 6)}…${trimmedAddress.slice(-4)}`, + } + + addBuyerToSession(address, buyerRecord) + + const updatedSession = patchPayerSessionFields(address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: trimmedAddress, + buyerPrvKey: null, + buyers: updatedSession.buyers, + activeBuyerAddress: trimmedAddress, + operatorConsented: false, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + monthlyStreamG: null, + error: null, + }), + ) + }, + [address], + ) + const handleSignOperatorConsent = useCallback(async () => { const currentState = state if (!currentState.address || !currentState.buyerPubKey || !currentState.buyerPrvKey) { @@ -1120,11 +1448,28 @@ export function useAiCreditsAdapter({ handleSetActiveTab('buy') }, [handleSetActiveTab]) + // Parse URL GET parameters for deep-link buyer assignment (runs once on mount). + // The effect intentionally runs only once — URL params are read at mount time only. + useEffect(() => { + if (typeof window === 'undefined') return + const params = new URLSearchParams(window.location.search) + const urlBuyerAddress = params.get('buyerAddress') + const urlBuyerSignature = params.get('buyerSignature') + if (urlBuyerAddress && urlBuyerSignature) { + void handleApplyDeepLinkBuyer(urlBuyerAddress, urlBuyerSignature) + } + }, [handleApplyDeepLinkBuyer]) + const actions: AiCreditsWidgetAdapterActions = useMemo( () => ({ connect: handleConnect, switchChain: handleSwitchChain, generateBuyerKey: handleGenerateBuyerKey, + createBuyer: handleCreateBuyer, + selectBuyer: handleSelectBuyer, + importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, + selectBuyerByAddress: handleSelectBuyerByAddress, + applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, buildQuote: handleBuildQuote, @@ -1141,6 +1486,11 @@ export function useAiCreditsAdapter({ handleConnect, handleSwitchChain, handleGenerateBuyerKey, + handleCreateBuyer, + handleSelectBuyer, + handleImportBuyerFromPrivateKey, + handleSelectBuyerByAddress, + handleApplyDeepLinkBuyer, handleSignOperatorConsent, handleSyncOperatorConsentFromChain, handleBuildQuote, diff --git a/packages/ai-credits-widget/src/buyerKeyDerivation.ts b/packages/ai-credits-widget/src/buyerKeyDerivation.ts index 3c39b0e4..c5e77461 100644 --- a/packages/ai-credits-widget/src/buyerKeyDerivation.ts +++ b/packages/ai-credits-widget/src/buyerKeyDerivation.ts @@ -3,8 +3,14 @@ import { privateKeyToAccount } from 'viem/accounts' const SECP256K1_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n -export function buildBuyerKeyMessage(payerAddress: string): string { - return `Generate a key for G$ credits from payer wallet of '${payerAddress.toLowerCase()}'` +/** + * Builds the wallet-signature message used to derive a buyer private key. + * Index 0 preserves the legacy single-buyer message for backward compatibility. + * Index > 0 appends the index so each buyer gets a unique deterministic key. + */ +export function buildBuyerKeyMessage(payerAddress: string, buyerIndex = 0): string { + const base = `Generate a key for G$ credits from payer wallet of '${payerAddress.toLowerCase()}'` + return buyerIndex === 0 ? base : `${base} (buyer ${buyerIndex})` } export function deriveBuyerPrivateKeyFromSignature(signature: Hex): `0x${string}` { diff --git a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx index b7225d17..ea19fd02 100644 --- a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx +++ b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx @@ -18,10 +18,12 @@ import { compactButtonProps } from '../shared/styles' import type { AiCreditsHistoryActions, AiCreditsHistoryState, + BuyerAddressFilter, CreditHistorySource, CreditHistoryStatusFilter, } from '../../useAiCreditsHistory' import { + BUYER_FILTER_ALL, HISTORY_LOOKBACK_DAYS, getLast90DaysRange, } from '../../useAiCreditsHistory' @@ -42,6 +44,8 @@ const SOURCE_PILL_OPTIONS: { id: CreditHistorySource; label: string }[] = [ export interface HistoryTabProps { state: AiCreditsHistoryState actions: AiCreditsHistoryActions + /** Known buyer records to populate the buyer filter dropdown. */ + knownBuyers?: { address: string; label?: string }[] } function sourceLabel(source: CreditHistorySource): string { @@ -448,10 +452,103 @@ function StatusFilterSelect({ ) } -export function HistoryTab({ state, actions }: HistoryTabProps) { +/** Dropdown for filtering history entries by buyer address. */ +function BuyerFilterSelect({ + value, + buyers, + onValueChange, +}: { + value: BuyerAddressFilter + buyers: { address: string; label?: string }[] + onValueChange: (value: BuyerAddressFilter) => void +}) { + const [open, setOpen] = useState(false) + + // Only render when there are known buyers to filter by + if (buyers.length === 0) return null + + const options = [ + { value: BUYER_FILTER_ALL, label: 'All buyers' }, + ...buyers.map((b) => ({ + value: b.address, + label: b.label ?? `${b.address.slice(0, 6)}…${b.address.slice(-4)}`, + })), + ] + + const selected = options.find((o) => o.value === value) ?? options[0] + + return ( + + setOpen((current) => !current)} + > + + Buyer: {selected?.label ?? 'All buyers'} + + + + + {open ? ( + + {options.map((option) => ( + { + onValueChange(option.value) + setOpen(false) + }} + > + + {option.label} + + + ))} + + ) : null} + + ) +} + +export function HistoryTab({ state, actions, knownBuyers = [] }: HistoryTabProps) { const { selectedSources, statusFilter, + buyerAddressFilter, fromDate, toDate, entries, @@ -512,6 +609,11 @@ export function HistoryTab({ state, actions }: HistoryTabProps) { value={statusFilter} onValueChange={actions.setStatusFilter} /> + diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index cb961342..1f97f043 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' -import { Button, ButtonText, Card, Heading, Icon, Spinner, Text, XStack, YStack } from '@goodwidget/ui' +import { Button, ButtonText, Card, Heading, Icon, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract' +import type { BuyerRecord } from '../../payerSession' import { AddressView } from '../shared/AddressView' import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles' import { useCopyFeedback } from '../shared/useCopyFeedback' @@ -8,17 +9,194 @@ import { useCopyFeedback } from '../shared/useCopyFeedback' interface BuyerOperatorCardProps { state: Pick< AiCreditsWidgetAdapterState, - 'address' | 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' + 'address' | 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' | 'buyers' | 'activeBuyerAddress' > - actions: Pick + actions: Pick< + AiCreditsWidgetAdapterActions, + | 'generateBuyerKey' + | 'createBuyer' + | 'selectBuyer' + | 'importBuyerFromPrivateKey' + | 'selectBuyerByAddress' + | 'signOperatorConsent' + > +} + +/** Displays a short label for a buyer in the selector list. */ +function buyerDisplayLabel(buyer: BuyerRecord): string { + if (buyer.label) return buyer.label + const shortAddr = `${buyer.address.slice(0, 6)}…${buyer.address.slice(-4)}` + if (buyer.type === 'address-only') return `Watch ${shortAddr}` + if (buyer.type === 'imported') return `Import ${shortAddr}` + return shortAddr +} + +/** Renders the buyer selection list when multiple buyers are present. */ +function BuyerSelector({ + buyers, + activeBuyerAddress, + onSelect, +}: { + buyers: BuyerRecord[] + activeBuyerAddress: string | null + onSelect: (address: string) => void +}) { + if (buyers.length <= 1) return null + + return ( + + + Buyers + + + {buyers.map((buyer) => { + const isActive = buyer.address.toLowerCase() === activeBuyerAddress?.toLowerCase() + return ( + { + if (!isActive) onSelect(buyer.address) + }} + > + + + {buyerDisplayLabel(buyer)} + + + {buyer.address.slice(0, 10)}…{buyer.address.slice(-6)} + + + {buyer.type === 'address-only' && ( + + view only + + )} + {isActive && } + + ) + })} + + + ) +} + +/** Collapsible panel to import a buyer from a hex private key or address. */ +function BuyerImportPanel({ + onImportPrivateKey, + onSelectAddress, +}: { + onImportPrivateKey: (key: string) => Promise + onSelectAddress: (address: string) => void +}) { + const [mode, setMode] = useState<'none' | 'private-key' | 'address'>('none') + const [inputValue, setInputValue] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + function handleCancel() { + setMode('none') + setInputValue('') + } + + async function handleSubmit() { + if (!inputValue.trim()) return + setIsSubmitting(true) + try { + if (mode === 'private-key') { + await onImportPrivateKey(inputValue.trim()) + } else { + onSelectAddress(inputValue.trim()) + } + setInputValue('') + setMode('none') + } finally { + setIsSubmitting(false) + } + } + + if (mode === 'none') { + return ( + + + + + ) + } + + return ( + + + {mode === 'private-key' ? 'Paste private key (0x…)' : 'Paste buyer address (0x…)'} + + + + + + + + ) } export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { - const { address, buyerPubKey, buyerPrvKey, operatorConsented } = state + const { address, buyerPubKey, buyerPrvKey, operatorConsented, buyers, activeBuyerAddress } = state const { copied: copiedPrivate, copy: copyPrivate } = useCopyFeedback() const [isPrivateKeyVisible, setIsPrivateKeyVisible] = useState(false) const [isGenerating, setIsGenerating] = useState(false) + const [isCreating, setIsCreating] = useState(false) const [isSigning, setIsSigning] = useState(false) + const [showImport, setShowImport] = useState(false) + + /** The buyer has a private key and can sign transactions. */ + const buyerCanSign = Boolean(buyerPrvKey) + const hasBuyers = buyers.length > 0 return ( @@ -27,21 +205,50 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { {address && } {buyerPubKey && } + {/* Buyer selector — visible only when more than one buyer exists */} + + + {/* Primary buyer action buttons */} - + {!hasBuyers ? ( + // First buyer: sign & generate deterministic key + + ) : ( + // Additional buyer: create next derived buyer + + )} + {/* Import / address-only section */} + {showImport ? ( + + ) : ( + + )} + + {/* Private key reveal section */} {buyerPrvKey && ( diff --git a/packages/ai-credits-widget/src/index.ts b/packages/ai-credits-widget/src/index.ts index 2eed7f08..2fd8a7d8 100644 --- a/packages/ai-credits-widget/src/index.ts +++ b/packages/ai-credits-widget/src/index.ts @@ -14,8 +14,11 @@ export type { AiCreditsPaySuccessDetail, AiCreditsPayErrorDetail, AiCreditsQuote, + BuyerRecord, } from './widgetRuntimeContract' +export type { BuyerIdentityType } from './payerSession' + export type { AiCreditsBackendClient, AccountRef, diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index 336c273a..d6173ff6 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -1,6 +1,29 @@ +/** Indicates how a buyer identity was created or loaded. */ +export type BuyerIdentityType = 'derived' | 'imported' | 'address-only' + +/** A single buyer identity stored per payer session. */ +export type BuyerRecord = { + /** Buyer's public address (checksummed or lowercase). */ + address: string + /** Private key – absent for address-only buyers. */ + privateKey?: string + /** How the buyer was created. */ + type: BuyerIdentityType + /** + * Derivation index used when signing the wallet message (undefined for + * imported/address-only buyers). + */ + derivationIndex?: number + /** Optional human-readable label shown in the UI. */ + label?: string +} + export type PayerWalletSession = { - buyerPubKey?: string - buyerPrvKey?: string + /** Ordered list of buyer identities known for this payer. */ + buyers: BuyerRecord[] + /** Address of the currently active buyer, or null if none selected. */ + activeBuyerAddress: string | null + /** Whether operator consent has been granted for the active buyer. */ operatorConsented: boolean } @@ -10,36 +33,130 @@ function payerSessionKey(address: string): string { return address.toLowerCase() } +/** + * Reads the session for the given payer address. + * Automatically migrates legacy single-buyer sessions into the new buyers array format. + */ export function readPayerSession(address: string | null): PayerWalletSession | null { if (!address) return null - return payerWalletSessions.get(payerSessionKey(address)) ?? null + const raw = payerWalletSessions.get(payerSessionKey(address)) + if (!raw) return null + + // Migration: old sessions may not have the `buyers` array yet + if (!Array.isArray((raw as unknown as Record).buyers)) { + const legacy = raw as unknown as { + buyerPubKey?: string + buyerPrvKey?: string + operatorConsented: boolean + } + const migrated: PayerWalletSession = { + buyers: legacy.buyerPubKey + ? [ + { + address: legacy.buyerPubKey, + privateKey: legacy.buyerPrvKey, + type: 'derived', + derivationIndex: 0, + label: 'Buyer 1', + }, + ] + : [], + activeBuyerAddress: legacy.buyerPubKey ?? null, + operatorConsented: legacy.operatorConsented, + } + payerWalletSessions.set(payerSessionKey(address), migrated) + return migrated + } + + return raw } export function patchPayerSession(address: string, patch: Partial): void { const existing = readPayerSession(address) payerWalletSessions.set(payerSessionKey(address), { + buyers: [], + activeBuyerAddress: null, operatorConsented: false, ...existing, ...patch, }) } +/** + * Adds a buyer record to the session if it is not already present, + * and sets it as the active buyer. + */ +export function addBuyerToSession(address: string, buyer: BuyerRecord): void { + const existing = readPayerSession(address) ?? { + buyers: [], + activeBuyerAddress: null, + operatorConsented: false, + } + + const alreadyExists = existing.buyers.some( + (b) => b.address.toLowerCase() === buyer.address.toLowerCase(), + ) + + const updatedBuyers = alreadyExists + ? existing.buyers.map((b) => + b.address.toLowerCase() === buyer.address.toLowerCase() ? { ...b, ...buyer } : b, + ) + : [...existing.buyers, buyer] + + payerWalletSessions.set(payerSessionKey(address), { + ...existing, + buyers: updatedBuyers, + activeBuyerAddress: buyer.address, + }) +} + +/** + * Returns the active buyer record from the session, or null if none exists. + */ +export function getActiveBuyer(address: string | null): BuyerRecord | null { + const session = readPayerSession(address) + if (!session?.activeBuyerAddress) return null + return ( + session.buyers.find( + (b) => b.address.toLowerCase() === session.activeBuyerAddress!.toLowerCase(), + ) ?? null + ) +} + +/** + * Returns the fields derived from the active buyer for state initialization. + * Keeps the same interface shape as the former `patchPayerSessionFields` so + * existing call-sites continue to work. + */ export function patchPayerSessionFields(address: string | null): { buyerPubKey?: string | null buyerPrvKey: string | null operatorConsented: boolean + buyers: BuyerRecord[] + activeBuyerAddress: string | null } { const session = readPayerSession(address) if (!session) { return { buyerPrvKey: null, operatorConsented: false, + buyers: [], + activeBuyerAddress: null, } } + + const active = session.activeBuyerAddress + ? session.buyers.find( + (b) => b.address.toLowerCase() === session.activeBuyerAddress!.toLowerCase(), + ) + : null + return { - buyerPubKey: session.buyerPubKey, - buyerPrvKey: session.buyerPrvKey ?? null, + buyerPubKey: active?.address ?? null, + buyerPrvKey: active?.privateKey ?? null, operatorConsented: session.operatorConsented, + buyers: session.buyers, + activeBuyerAddress: session.activeBuyerAddress ?? null, } } diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts index 9a23318a..2962a249 100644 --- a/packages/ai-credits-widget/src/useAiCreditsHistory.ts +++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts @@ -8,6 +8,10 @@ export const HISTORY_LOOKBACK_DAYS = 90 export type CreditHistorySource = GdCreditEntry['source'] export type CreditHistoryStatusFilter = 'all' | GdCreditEntry['fundingStatus'] +/** Special sentinel meaning "show entries for every known buyer". */ +export const BUYER_FILTER_ALL = 'all' as const +export type BuyerAddressFilter = typeof BUYER_FILTER_ALL | string + export const HISTORY_SOURCE_OPTIONS: { id: CreditHistorySource label: string @@ -56,6 +60,8 @@ function toIsoEndOfDay(dateValue: string): string | undefined { export interface AiCreditsHistoryState { selectedSources: Record statusFilter: CreditHistoryStatusFilter + /** Address of the selected buyer to filter by, or `'all'` to show all buyers. */ + buyerAddressFilter: BuyerAddressFilter fromDate: string toDate: string entries: GdCreditEntry[] @@ -70,6 +76,8 @@ export interface AiCreditsHistoryState { export interface AiCreditsHistoryActions { setSourceChecked: (source: CreditHistorySource, checked: boolean) => void setStatusFilter: (status: CreditHistoryStatusFilter) => void + /** Sets the buyer address filter; pass `'all'` to show all buyers. */ + setBuyerAddressFilter: (value: BuyerAddressFilter) => void setFromDate: (value: string) => void setToDate: (value: string) => void reload: () => Promise @@ -84,12 +92,15 @@ export interface UseAiCreditsHistoryResult { export function useAiCreditsHistory(options: { address: string | null backendUrl?: string + /** Default buyer address filter; defaults to `'all'`. */ + defaultBuyerFilter?: BuyerAddressFilter }): UseAiCreditsHistoryResult { - const { address, backendUrl } = options + const { address, backendUrl, defaultBuyerFilter = BUYER_FILTER_ALL } = options const defaultRange = useMemo(() => getLast90DaysRange(), []) const [selectedSources, setSelectedSources] = useState(createDefaultSelectedSources) const [statusFilter, setStatusFilter] = useState('all') + const [buyerAddressFilter, setBuyerAddressFilter] = useState(defaultBuyerFilter) const [fromDate, setFromDate] = useState(defaultRange.from) const [toDate, setToDate] = useState(defaultRange.to) const [entries, setEntries] = useState([]) @@ -143,12 +154,23 @@ export function useAiCreditsHistory(options: { from: toIsoStartOfDay(fromDate), to: toIsoEndOfDay(toDate), }) - const pageItems = + + // Apply client-side source filter for multi-source queries + const sourceFiltered = activeSources.length === 1 ? response.items : response.items.filter((entry) => selectedSources[entry.source]) - setEntries((prev) => (append ? [...prev, ...pageItems] : pageItems)) + // Apply buyer address filter on the client side using the `buyerAddress` field + const buyerFiltered = + buyerAddressFilter === BUYER_FILTER_ALL + ? sourceFiltered + : sourceFiltered.filter( + (entry) => + entry.buyerAddress?.toLowerCase() === buyerAddressFilter.toLowerCase(), + ) + + setEntries((prev) => (append ? [...prev, ...buyerFiltered] : buyerFiltered)) setOffset(nextOffset) setHasMore(response.hasMore) } catch (err: unknown) { @@ -160,7 +182,7 @@ export function useAiCreditsHistory(options: { setLoadingMore(false) } }, - [address, backendUrl, activeSources, statusFilter, fromDate, toDate, selectedSources], + [address, backendUrl, activeSources, statusFilter, buyerAddressFilter, fromDate, toDate, selectedSources], ) useEffect(() => { @@ -188,6 +210,7 @@ export function useAiCreditsHistory(options: { state: { selectedSources, statusFilter, + buyerAddressFilter, fromDate, toDate, entries, @@ -201,6 +224,7 @@ export function useAiCreditsHistory(options: { actions: { setSourceChecked, setStatusFilter, + setBuyerAddressFilter, setFromDate, setToDate, reload, diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index a3aeebe8..2b209f9e 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -1,5 +1,6 @@ import type { Address } from 'viem' import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { BuyerRecord } from './payerSession' export type AiCreditsWidgetEnvironment = 'production' | 'staging' | 'development' @@ -22,6 +23,9 @@ export interface AiCreditsQuote { streamAmountG: string } +/** Re-export for consumers that don't want to import from payerSession directly. */ +export type { BuyerRecord } + export interface AiCreditsWidgetAdapterState { status: AiCreditsWidgetStatus address: string | null @@ -30,7 +34,9 @@ export interface AiCreditsWidgetAdapterState { gdUsdPerToken: number | null totalCreditUsd: string | null isGoodIdVerified: boolean + /** Active buyer public address (derived from `buyers` + `activeBuyerAddress`). */ buyerPubKey: string | null + /** Active buyer private key – absent for address-only buyers. */ buyerPrvKey: string | null operatorConsented: boolean operatorAddress: string | null @@ -43,12 +49,45 @@ export interface AiCreditsWidgetAdapterState { streamBonusPercent: number error: string | null activeTab: AiCreditsWidgetTab + /** All buyer identities known for the connected payer in this session. */ + buyers: BuyerRecord[] + /** Address of the currently selected buyer (matches `buyerPubKey`). */ + activeBuyerAddress: string | null } export interface AiCreditsWidgetAdapterActions { connect: () => Promise switchChain: () => Promise + /** + * Creates a new deterministically-derived buyer by signing a wallet message. + * The index is automatically assigned as the next available slot. + */ generateBuyerKey: () => Promise + /** + * Creates an additional deterministic buyer (next derivation index). + * Alias kept separate from `generateBuyerKey` for UI clarity. + */ + createBuyer: () => Promise + /** + * Switches the active buyer to an existing buyer in the session. + * The buyer must already be present in `state.buyers`. + */ + selectBuyer: (address: string) => void + /** + * Imports a buyer identity from a hex private key string. + * Validates the key format before accepting. + */ + importBuyerFromPrivateKey: (privateKey: string) => Promise + /** + * Registers a buyer address without a private key (view/consent-pairing mode). + * Sign-required actions will be disabled for this buyer. + */ + selectBuyerByAddress: (address: string) => void + /** + * Applies a deep-link buyer assignment from URL GET parameters. + * Validates the provided buyer address and signature before accepting. + */ + applyDeepLinkBuyer: (address: string, signature: string) => Promise signOperatorConsent: () => Promise syncOperatorConsentFromChain: () => Promise buildQuote: (depositG: string, streamG: string) => Promise @@ -108,3 +147,4 @@ export interface AiCreditsWidgetProps { adapterFactory?: AiCreditsWidgetAdapterFactory testId?: string } + diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts index 5bbdfff3..15ceb1a5 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -221,3 +221,78 @@ test('AiCreditsWidget appkit connect wallet opens modal', async ({ page }) => { fullPage: true, }) }) + +// --------------------------------------------------------------------------- +// Multi-buyer tests +// --------------------------------------------------------------------------- + +const MULTI_BUYER_STORY_IDS = { + multiBuyerManage: + '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-manage&viewMode=story', + addressOnlyBuyer: + '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--address-only-buyer&viewMode=story', + multiBuyerHistory: + '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-history&viewMode=story', +} as const + +test('AiCreditsWidget multi-buyer manage: buyer selector is visible', async ({ page }) => { + await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerManage) + const root = widget(page, 'AiCreditsWidget-multi-buyer-manage') + await expect(root).toBeVisible() + + // Buyer selector should list all buyers when more than one exists + await expect(root.getByText('Buyer 1')).toBeVisible() + await expect(root.getByText('Buyer 2')).toBeVisible() + + // New Buyer button should be visible (not the first-time Sign & Generate) + await expect(root.getByRole('button', { name: /New Buyer/i })).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-15-multi-buyer-manage.png', + fullPage: true, + }) +}) + +test('AiCreditsWidget address-only buyer: sign-required actions disabled', async ({ page }) => { + await gotoStory(page, MULTI_BUYER_STORY_IDS.addressOnlyBuyer) + const root = widget(page, 'AiCreditsWidget-address-only-buyer') + await expect(root).toBeVisible() + + // Sign Consent button should be disabled for address-only buyer + const signConsentButton = root.getByRole('button', { name: /Sign Consent/i }) + await expect(signConsentButton).toBeDisabled() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-16-address-only-buyer.png', + fullPage: true, + }) +}) + +test('AiCreditsWidget multi-buyer history: buyer filter dropdown is visible', async ({ page }) => { + await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerHistory) + const root = widget(page, 'AiCreditsWidget-multi-buyer-history') + await expect(root).toBeVisible() + + // History tab should show a buyer filter that starts with "All buyers" + await expect(root.getByText(/Buyer: All buyers/i)).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-17-multi-buyer-history.png', + fullPage: true, + }) +}) + +test('AiCreditsWidget multi-buyer: import or watch link is visible', async ({ page }) => { + await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerManage) + const root = widget(page, 'AiCreditsWidget-multi-buyer-manage') + await expect(root).toBeVisible() + + // Import/watch link should always be available in the Buyer & Operator card + await expect(root.getByText(/Import or watch a buyer/i)).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-18-import-watch-link.png', + fullPage: true, + }) +}) + From 3494f6035f5319463d74b2100740abcc3df3afdf Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 31 Jul 2026 15:19:25 -0400 Subject: [PATCH 03/17] feat: implement deep link handling for buyer registration in AI Credits widget - Added support for deep link parameters, including buyer address and operator signature, to facilitate buyer registration via NCDI deep links. - Introduced validation functions for buyer address and operator signature. - Enhanced state management to handle operator consent and session updates based on deep link data. - Updated relevant components and types to accommodate new deep link functionality, ensuring a seamless user experience when applying deep links. --- packages/ai-credits-widget/src/adapter.ts | 224 +++++++++++++++--- .../components/manage/BuyerOperatorCard.tsx | 8 +- .../ai-credits-widget/src/deepLinkParams.ts | 66 ++++++ packages/ai-credits-widget/src/index.ts | 7 + .../ai-credits-widget/src/payerSession.ts | 22 +- .../src/widgetRuntimeContract.ts | 7 +- 6 files changed, 290 insertions(+), 44 deletions(-) create mode 100644 packages/ai-credits-widget/src/deepLinkParams.ts diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 92925500..04b4f24c 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -32,6 +32,13 @@ import { } from './chainClient' import type { AiCreditsChainClient } from './chainClient' import { signOperatorConsentFromTypedData } from './operatorConsent' +import { + isValidBuyerAddress, + isValidOperatorSignature, + parseDeepLinkParams, + stripDeepLinkParamsFromUrl, + type DeepLinkParams, +} from './deepLinkParams' import { addressesMatch, patchPayerSessionFields, @@ -862,51 +869,76 @@ export function useAiCreditsAdapter({ ) /** - * Applies a deep-link buyer assignment from URL GET parameters. - * Validates that the provided address is a valid EVM address and that - * the accompanying signature is a non-empty hex string before accepting. - * The buyer is registered as an address-only identity since we do not - * receive the private key through the URL. + * Registers a buyer from an NCDI deep link and submits the pre-signed + * operator-approval token. Never stores a buyer private key from the URL. */ const handleApplyDeepLinkBuyer = useCallback( - async (buyerAddress: string, buyerSignature: string) => { + async (buyerAddress: string, operatorSignature: string) => { if (!address) { - setState((prev) => - withDerivedStatus(prev, { error: 'Connect your wallet before applying a deep-link buyer' }, true), - ) return } const trimmedAddress = buyerAddress.trim() - const trimmedSignature = buyerSignature.trim() + const trimmedSignature = operatorSignature.trim() - if (!/^0x[0-9a-fA-F]{40}$/.test(trimmedAddress)) { + if (!isValidBuyerAddress(trimmedAddress)) { setState((prev) => - withDerivedStatus(prev, { error: 'Deep-link buyer address is invalid' }, true), + withDerivedStatus( + prev, + { + error: + 'Deep-link buyerAddress is invalid. Select or import a buyer manually to continue.', + }, + true, + ), ) return } - if (!/^0x[0-9a-fA-F]{2,}$/.test(trimmedSignature)) { + if (!isValidOperatorSignature(trimmedSignature)) { setState((prev) => - withDerivedStatus(prev, { error: 'Deep-link buyer signature is invalid' }, true), + withDerivedStatus( + prev, + { + error: + 'Deep-link operatorSignature is invalid. Select or import a buyer manually to continue.', + }, + true, + ), ) return } + const existingSession = patchPayerSessionFields(address) + const existingBuyer = existingSession.buyers.find( + (b) => b.address.toLowerCase() === trimmedAddress.toLowerCase(), + ) + const buyerRecord: BuyerRecord = { address: trimmedAddress, - type: 'address-only', - label: `Partner ${trimmedAddress.slice(0, 6)}…${trimmedAddress.slice(-4)}`, + type: existingBuyer?.privateKey ? existingBuyer.type : 'address-only', + ...(existingBuyer?.privateKey ? { privateKey: existingBuyer.privateKey } : {}), + ...(existingBuyer?.derivationIndex !== undefined + ? { derivationIndex: existingBuyer.derivationIndex } + : {}), + label: + existingBuyer?.label ?? + `Partner ${trimmedAddress.slice(0, 6)}…${trimmedAddress.slice(-4)}`, + operatorSignature: trimmedSignature, } addBuyerToSession(address, buyerRecord) + patchPayerSession(address, { + activeBuyerAddress: trimmedAddress, + operatorSignature: trimmedSignature, + operatorConsented: false, + }) const updatedSession = patchPayerSessionFields(address) setState((prev) => mergeStatePreservingNonBuyTab(prev, { buyerPubKey: trimmedAddress, - buyerPrvKey: null, + buyerPrvKey: existingBuyer?.privateKey ?? null, buyers: updatedSession.buyers, activeBuyerAddress: trimmedAddress, operatorConsented: false, @@ -915,16 +947,88 @@ export function useAiCreditsAdapter({ withdrawableUsd: null, totalGdDepositedG: null, monthlyStreamG: null, + activeTab: 'buy', error: null, }), ) + + const ref: AccountRef = { payer: address, buyer: trimmedAddress } + + try { + const operatorStatus = await chainClient.getBuyerOperatorStatus(ref) + + if (!operatorStatus.enabled) { + throw new Error('Operator consent is not available for this deep-link buyer') + } + + if (!operatorStatus.operatorAccepted) { + await backendClient.submitOperatorConsent(ref.buyer, { + nonce: operatorStatus.consentNonce, + signature: trimmedSignature, + }) + await waitForOperatorConsent(chainClient, ref) + } + + patchPayerSession(address, { + operatorConsented: true, + operatorSignature: trimmedSignature, + }) + setState((prev) => + withDerivedStatus( + prev, + { + buyerPubKey: trimmedAddress, + buyerPrvKey: existingBuyer?.privateKey ?? null, + buyers: updatedSession.buyers, + activeBuyerAddress: trimmedAddress, + operatorConsented: true, + activeTab: 'buy', + error: null, + }, + true, + ), + ) + stripDeepLinkParamsFromUrl() + } catch (err: unknown) { + setState((prev) => + withDerivedStatus( + prev, + { + error: + err instanceof Error + ? err.message + : 'Could not apply deep-link operator approval. Select a buyer manually to continue.', + activeTab: 'buy', + }, + true, + ), + ) + } }, - [address], + [address, backendClient, chainClient], ) const handleSignOperatorConsent = useCallback(async () => { const currentState = state - if (!currentState.address || !currentState.buyerPubKey || !currentState.buyerPrvKey) { + if (!currentState.address || !currentState.buyerPubKey) { + setState((prev) => + withDerivedStatus( + prev, + { error: 'Select a buyer before signing operator consent' }, + true, + ), + ) + return + } + + const session = readPayerSession(currentState.address) + const activeBuyer = session?.buyers.find( + (b) => b.address.toLowerCase() === currentState.buyerPubKey!.toLowerCase(), + ) + const storedOperatorSignature = + activeBuyer?.operatorSignature ?? session?.operatorSignature ?? null + + if (!currentState.buyerPrvKey && !storedOperatorSignature) { setState((prev) => withDerivedStatus( prev, @@ -957,16 +1061,21 @@ export function useAiCreditsAdapter({ return } - const payload = await chainClient.buildOperatorConsentPayload(ref, operatorStatus) + let buyerSig: `0x${string}` + if (storedOperatorSignature) { + buyerSig = storedOperatorSignature as `0x${string}` + } else { + const payload = await chainClient.buildOperatorConsentPayload(ref, operatorStatus) - if (!payload.enabled || !payload.typedData) { - throw new Error('Operator consent is not available') - } + if (!payload.enabled || !payload.typedData) { + throw new Error('Operator consent is not available') + } - const buyerSig = await signOperatorConsentFromTypedData( - currentState.buyerPrvKey as `0x${string}`, - payload.typedData, - ) + buyerSig = await signOperatorConsentFromTypedData( + currentState.buyerPrvKey as `0x${string}`, + payload.typedData, + ) + } await backendClient.submitOperatorConsent(ref.buyer, { nonce: operatorStatus.consentNonce, @@ -1448,17 +1557,60 @@ export function useAiCreditsAdapter({ handleSetActiveTab('buy') }, [handleSetActiveTab]) - // Parse URL GET parameters for deep-link buyer assignment (runs once on mount). - // The effect intentionally runs only once — URL params are read at mount time only. + const pendingDeepLinkRef = useRef(null) + const deepLinkParseDoneRef = useRef(false) + const deepLinkApplyInFlightRef = useRef(false) + useEffect(() => { - if (typeof window === 'undefined') return - const params = new URLSearchParams(window.location.search) - const urlBuyerAddress = params.get('buyerAddress') - const urlBuyerSignature = params.get('buyerSignature') - if (urlBuyerAddress && urlBuyerSignature) { - void handleApplyDeepLinkBuyer(urlBuyerAddress, urlBuyerSignature) + if (typeof window === 'undefined' || deepLinkParseDoneRef.current) return + deepLinkParseDoneRef.current = true + + const parsed = parseDeepLinkParams(window.location.search) + if (parsed.status === 'absent') return + + if (parsed.status === 'partial') { + const missing = + parsed.present === 'buyerAddress' ? 'operatorSignature' : 'buyerAddress' + setState((prev) => + withDerivedStatus( + prev, + { + error: `Deep link is missing ${missing}. Select or import a buyer manually to continue.`, + activeTab: 'buy', + }, + false, + ), + ) + return + } + + if (parsed.status === 'invalid') { + setState((prev) => + withDerivedStatus( + prev, + { + error: `${parsed.reason}. Select or import a buyer manually to continue.`, + activeTab: 'buy', + }, + false, + ), + ) + return } - }, [handleApplyDeepLinkBuyer]) + + pendingDeepLinkRef.current = parsed.value + }, []) + + useEffect(() => { + const pending = pendingDeepLinkRef.current + if (!address || !pending || deepLinkApplyInFlightRef.current) return + + deepLinkApplyInFlightRef.current = true + void handleApplyDeepLinkBuyer(pending.buyerAddress, pending.operatorSignature).finally(() => { + deepLinkApplyInFlightRef.current = false + pendingDeepLinkRef.current = null + }) + }, [address, handleApplyDeepLinkBuyer]) const actions: AiCreditsWidgetAdapterActions = useMemo( () => ({ diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index 1f97f043..c4ea1d03 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -194,8 +194,12 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { const [isSigning, setIsSigning] = useState(false) const [showImport, setShowImport] = useState(false) - /** The buyer has a private key and can sign transactions. */ - const buyerCanSign = Boolean(buyerPrvKey) + /** The buyer can approve the operator via private key or a stored deep-link signature. */ + const activeBuyer = buyers.find( + (buyer) => + buyer.address.toLowerCase() === (activeBuyerAddress ?? buyerPubKey ?? '').toLowerCase(), + ) + const buyerCanSign = Boolean(buyerPrvKey || activeBuyer?.operatorSignature) const hasBuyers = buyers.length > 0 return ( diff --git a/packages/ai-credits-widget/src/deepLinkParams.ts b/packages/ai-credits-widget/src/deepLinkParams.ts new file mode 100644 index 00000000..ebd33ad8 --- /dev/null +++ b/packages/ai-credits-widget/src/deepLinkParams.ts @@ -0,0 +1,66 @@ +export type DeepLinkParams = { + buyerAddress: string + operatorSignature: string +} + +export type DeepLinkParseResult = + | { status: 'absent' } + | { status: 'partial'; present: 'buyerAddress' | 'operatorSignature' } + | { status: 'invalid'; reason: string } + | { status: 'complete'; value: DeepLinkParams } + +const BUYER_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/ +const OPERATOR_SIGNATURE_RE = /^0x[0-9a-fA-F]{128}([0-9a-fA-F]{2})?$/ + +export function isValidBuyerAddress(value: string): boolean { + return BUYER_ADDRESS_RE.test(value.trim()) +} + +export function isValidOperatorSignature(value: string): boolean { + return OPERATOR_SIGNATURE_RE.test(value.trim()) +} + +export function parseDeepLinkParams( + search: string | URLSearchParams = typeof window !== 'undefined' ? window.location.search : '', +): DeepLinkParseResult { + const params = typeof search === 'string' ? new URLSearchParams(search) : search + const buyerAddress = params.get('buyerAddress')?.trim() ?? '' + const operatorSignature = params.get('operatorSignature')?.trim() ?? '' + + const hasBuyer = buyerAddress.length > 0 + const hasSignature = operatorSignature.length > 0 + + if (!hasBuyer && !hasSignature) return { status: 'absent' } + if (hasBuyer && !hasSignature) return { status: 'partial', present: 'buyerAddress' } + if (!hasBuyer && hasSignature) return { status: 'partial', present: 'operatorSignature' } + + if (!isValidBuyerAddress(buyerAddress)) { + return { status: 'invalid', reason: 'Deep-link buyerAddress is invalid' } + } + if (!isValidOperatorSignature(operatorSignature)) { + return { + status: 'invalid', + reason: 'Deep-link operatorSignature is invalid', + } + } + + return { + status: 'complete', + value: { + buyerAddress, + operatorSignature, + }, + } +} + +export function stripDeepLinkParamsFromUrl(): void { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + if (!url.searchParams.has('buyerAddress') && !url.searchParams.has('operatorSignature')) { + return + } + url.searchParams.delete('buyerAddress') + url.searchParams.delete('operatorSignature') + const next = `${url.pathname}${url.search}${url.hash}` + window.history.replaceState(window.history.state, '', next) +} diff --git a/packages/ai-credits-widget/src/index.ts b/packages/ai-credits-widget/src/index.ts index 2fd8a7d8..1a62143c 100644 --- a/packages/ai-credits-widget/src/index.ts +++ b/packages/ai-credits-widget/src/index.ts @@ -19,6 +19,13 @@ export type { export type { BuyerIdentityType } from './payerSession' +export { + parseDeepLinkParams, + isValidBuyerAddress, + isValidOperatorSignature, +} from './deepLinkParams' +export type { DeepLinkParams, DeepLinkParseResult } from './deepLinkParams' + export type { AiCreditsBackendClient, AccountRef, diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index d6173ff6..6e3948e0 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -16,6 +16,11 @@ export type BuyerRecord = { derivationIndex?: number /** Optional human-readable label shown in the UI. */ label?: string + /** + * Pre-signed operator-approval token from an external host (NCDI deep link). + * Used for operator-consent submission; never a buyer private key. + */ + operatorSignature?: string } export type PayerWalletSession = { @@ -25,6 +30,8 @@ export type PayerWalletSession = { activeBuyerAddress: string | null /** Whether operator consent has been granted for the active buyer. */ operatorConsented: boolean + /** Last deep-link operator signature retained for retries within this session. */ + operatorSignature?: string | null } const payerWalletSessions = new Map() @@ -98,15 +105,24 @@ export function addBuyerToSession(address: string, buyer: BuyerRecord): void { ) const updatedBuyers = alreadyExists - ? existing.buyers.map((b) => - b.address.toLowerCase() === buyer.address.toLowerCase() ? { ...b, ...buyer } : b, - ) + ? existing.buyers.map((b) => { + if (b.address.toLowerCase() !== buyer.address.toLowerCase()) return b + const privateKey = buyer.privateKey ?? b.privateKey + return { + ...b, + ...buyer, + privateKey, + type: privateKey ? (buyer.privateKey ? buyer.type : b.type) : 'address-only', + operatorSignature: buyer.operatorSignature ?? b.operatorSignature, + } + }) : [...existing.buyers, buyer] payerWalletSessions.set(payerSessionKey(address), { ...existing, buyers: updatedBuyers, activeBuyerAddress: buyer.address, + operatorSignature: buyer.operatorSignature ?? existing.operatorSignature ?? null, }) } diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 2b209f9e..2201a4a6 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -84,10 +84,11 @@ export interface AiCreditsWidgetAdapterActions { */ selectBuyerByAddress: (address: string) => void /** - * Applies a deep-link buyer assignment from URL GET parameters. - * Validates the provided buyer address and signature before accepting. + * Applies an NCDI deep-link buyer assignment from URL GET parameters + * (`buyerAddress` + `operatorSignature`). Submits the pre-signed operator + * approval token and starts the buy flow. Never accepts a buyer private key. */ - applyDeepLinkBuyer: (address: string, signature: string) => Promise + applyDeepLinkBuyer: (address: string, operatorSignature: string) => Promise signOperatorConsent: () => Promise syncOperatorConsentFromChain: () => Promise buildQuote: (depositG: string, streamG: string) => Promise From dad5b67d79b06e96f5251468c53d6acfb5771c25 Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 31 Jul 2026 15:19:47 -0400 Subject: [PATCH 04/17] feat: enhance deep link handling in AI Credits widget - Introduced new functions for managing deep link parameters, including storage and retrieval from local storage. - Updated error handling to provide clearer messages when deep link parameters are invalid or missing. - Improved state management to reflect deep link status and errors in the UI, ensuring a better user experience. - Added a fallback mechanism for deep link processing, enhancing robustness in various scenarios. --- .../ai-credits-widget/src/AiCreditsWidget.tsx | 9 ++ packages/ai-credits-widget/src/adapter.ts | 47 +++++++--- .../ai-credits-widget/src/deepLinkParams.ts | 85 ++++++++++++++++++- packages/ai-credits-widget/src/index.ts | 7 ++ 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index 191a62aa..4db22b52 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -184,6 +184,15 @@ function BuyCreditsPanel({ state, actions, isPending, onPay }: BuyPanelProps) { } else { content = ( <> + {state.error && ( + + + Deep link unavailable + + {state.error} + + )} + {state.address && ( b.address.toLowerCase() === trimmedAddress.toLowerCase(), @@ -988,17 +1005,19 @@ export function useAiCreditsAdapter({ true, ), ) - stripDeepLinkParamsFromUrl() + clearDeepLinkArtifacts() } catch (err: unknown) { setState((prev) => withDerivedStatus( prev, { - error: + error: deepLinkManualFallbackMessage( err instanceof Error ? err.message - : 'Could not apply deep-link operator approval. Select a buyer manually to continue.', + : 'Could not apply deep-link operator approval.', + ), activeTab: 'buy', + status: 'purchase_setup', }, true, ), @@ -1565,7 +1584,7 @@ export function useAiCreditsAdapter({ if (typeof window === 'undefined' || deepLinkParseDoneRef.current) return deepLinkParseDoneRef.current = true - const parsed = parseDeepLinkParams(window.location.search) + const parsed = resolveDeepLinkParams(window.location.search) if (parsed.status === 'absent') return if (parsed.status === 'partial') { @@ -1575,8 +1594,9 @@ export function useAiCreditsAdapter({ withDerivedStatus( prev, { - error: `Deep link is missing ${missing}. Select or import a buyer manually to continue.`, + error: deepLinkManualFallbackMessage(`Deep link is missing ${missing}.`), activeTab: 'buy', + status: 'purchase_setup', }, false, ), @@ -1589,8 +1609,9 @@ export function useAiCreditsAdapter({ withDerivedStatus( prev, { - error: `${parsed.reason}. Select or import a buyer manually to continue.`, + error: deepLinkManualFallbackMessage(`${parsed.reason}.`), activeTab: 'buy', + status: 'purchase_setup', }, false, ), diff --git a/packages/ai-credits-widget/src/deepLinkParams.ts b/packages/ai-credits-widget/src/deepLinkParams.ts index ebd33ad8..9e2b7417 100644 --- a/packages/ai-credits-widget/src/deepLinkParams.ts +++ b/packages/ai-credits-widget/src/deepLinkParams.ts @@ -7,10 +7,14 @@ export type DeepLinkParseResult = | { status: 'absent' } | { status: 'partial'; present: 'buyerAddress' | 'operatorSignature' } | { status: 'invalid'; reason: string } - | { status: 'complete'; value: DeepLinkParams } + | { status: 'complete'; value: DeepLinkParams; source: 'url' | 'storage' } const BUYER_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/ const OPERATOR_SIGNATURE_RE = /^0x[0-9a-fA-F]{128}([0-9a-fA-F]{2})?$/ +const DEEP_LINK_STORAGE_KEY = 'goodwidget.ai-credits.deepLink' + +export const DEEP_LINK_MANUAL_FALLBACK_HINT = + 'Select or import a buyer manually to continue.' export function isValidBuyerAddress(value: string): boolean { return BUYER_ADDRESS_RE.test(value.trim()) @@ -20,6 +24,58 @@ export function isValidOperatorSignature(value: string): boolean { return OPERATOR_SIGNATURE_RE.test(value.trim()) } +export function deepLinkManualFallbackMessage(reason: string): string { + return `${reason} ${DEEP_LINK_MANUAL_FALLBACK_HINT}` +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} + +export function readStoredDeepLinkParams(): DeepLinkParams | null { + if (!canUseLocalStorage()) return null + try { + const raw = window.localStorage.getItem(DEEP_LINK_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + const buyerAddress = typeof parsed.buyerAddress === 'string' ? parsed.buyerAddress.trim() : '' + const operatorSignature = + typeof parsed.operatorSignature === 'string' ? parsed.operatorSignature.trim() : '' + if (!isValidBuyerAddress(buyerAddress) || !isValidOperatorSignature(operatorSignature)) { + clearStoredDeepLinkParams() + return null + } + return { buyerAddress, operatorSignature } + } catch { + clearStoredDeepLinkParams() + return null + } +} + +export function storeDeepLinkParams(value: DeepLinkParams): void { + if (!canUseLocalStorage()) return + try { + window.localStorage.setItem( + DEEP_LINK_STORAGE_KEY, + JSON.stringify({ + buyerAddress: value.buyerAddress.trim(), + operatorSignature: value.operatorSignature.trim(), + }), + ) + } catch { + return + } +} + +export function clearStoredDeepLinkParams(): void { + if (!canUseLocalStorage()) return + try { + window.localStorage.removeItem(DEEP_LINK_STORAGE_KEY) + } catch { + return + } +} + export function parseDeepLinkParams( search: string | URLSearchParams = typeof window !== 'undefined' ? window.location.search : '', ): DeepLinkParseResult { @@ -46,6 +102,7 @@ export function parseDeepLinkParams( return { status: 'complete', + source: 'url', value: { buyerAddress, operatorSignature, @@ -53,6 +110,27 @@ export function parseDeepLinkParams( } } +/** + * Prefer live URL params; persist complete pairs to localStorage for refresh. + * If the URL has no deep-link params, fall back to a previously stored pair. + */ +export function resolveDeepLinkParams( + search: string | URLSearchParams = typeof window !== 'undefined' ? window.location.search : '', +): DeepLinkParseResult { + const fromUrl = parseDeepLinkParams(search) + if (fromUrl.status === 'complete') { + storeDeepLinkParams(fromUrl.value) + return fromUrl + } + if (fromUrl.status === 'partial' || fromUrl.status === 'invalid') { + return fromUrl + } + + const stored = readStoredDeepLinkParams() + if (!stored) return { status: 'absent' } + return { status: 'complete', source: 'storage', value: stored } +} + export function stripDeepLinkParamsFromUrl(): void { if (typeof window === 'undefined') return const url = new URL(window.location.href) @@ -64,3 +142,8 @@ export function stripDeepLinkParamsFromUrl(): void { const next = `${url.pathname}${url.search}${url.hash}` window.history.replaceState(window.history.state, '', next) } + +export function clearDeepLinkArtifacts(): void { + clearStoredDeepLinkParams() + stripDeepLinkParamsFromUrl() +} diff --git a/packages/ai-credits-widget/src/index.ts b/packages/ai-credits-widget/src/index.ts index 1a62143c..1b171f0f 100644 --- a/packages/ai-credits-widget/src/index.ts +++ b/packages/ai-credits-widget/src/index.ts @@ -21,8 +21,15 @@ export type { BuyerIdentityType } from './payerSession' export { parseDeepLinkParams, + resolveDeepLinkParams, isValidBuyerAddress, isValidOperatorSignature, + storeDeepLinkParams, + readStoredDeepLinkParams, + clearStoredDeepLinkParams, + clearDeepLinkArtifacts, + deepLinkManualFallbackMessage, + DEEP_LINK_MANUAL_FALLBACK_HINT, } from './deepLinkParams' export type { DeepLinkParams, DeepLinkParseResult } from './deepLinkParams' From 6895f099b3d88dc54a15e955e5c5d2238004fde0 Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 31 Jul 2026 17:07:03 -0400 Subject: [PATCH 05/17] refactor: streamline buyer key generation in AI Credits widget - Removed the previous implementation of handleGenerateBuyerKey, consolidating its functionality into handleCreateBuyer for clarity and to preserve backward compatibility. - Updated comments to reflect changes in buyer key generation logic, ensuring better understanding of the derivation index handling. - Enhanced the interface documentation for generateBuyerKey to clarify its behavior regarding existing buyers and derivation indices. --- packages/ai-credits-widget/src/adapter.ts | 84 ++++--------------- .../src/widgetRuntimeContract.ts | 5 +- 2 files changed, 17 insertions(+), 72 deletions(-) diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 9ff82d3d..19991c44 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -583,72 +583,9 @@ export function useAiCreditsAdapter({ }) }, []) - const handleGenerateBuyerKey = useCallback(async () => { - if (!address || !providerRef.current) { - setState((prev) => - withDerivedStatus( - prev, - { error: 'Connect your wallet before generating a buyer key' }, - true, - ), - ) - return - } - - try { - const payerAddress = address as Address - // Use index 0 for the first/default buyer to preserve backward compatibility - const nextIndex = 0 - const message = buildBuyerKeyMessage(payerAddress, nextIndex) - const walletClient = createWalletClient({ - account: payerAddress, - chain: CELO_CHAIN, - transport: custom(providerRef.current), - }) - const signature = await walletClient.signMessage({ - account: payerAddress, - message, - }) - const privateKey = deriveBuyerPrivateKeyFromSignature(signature) - const buyerAccount = privateKeyToAccount(privateKey) - const label = 'Buyer 1' - - const buyerRecord: BuyerRecord = { - address: buyerAccount.address, - privateKey, - type: 'derived', - derivationIndex: nextIndex, - label, - } - addBuyerToSession(payerAddress, buyerRecord) - - const updatedSession = patchPayerSessionFields(payerAddress) - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: buyerAccount.address, - buyerPrvKey: privateKey, - buyers: updatedSession.buyers, - activeBuyerAddress: buyerAccount.address, - error: null, - ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }), - ) - } catch (err: unknown) { - setState((prev) => - withDerivedStatus( - prev, - { - error: err instanceof Error ? err.message : 'Buyer key generation was rejected', - }, - true, - ), - ) - } - }, [address]) - /** - * Creates a new derived buyer at the next available derivation index. - * Prompts the connected wallet to sign a unique message for each buyer. + * Creates a derived buyer at the next available derivation index. + * Index 0 preserves the legacy single-buyer message for backward compatibility. */ const handleCreateBuyer = useCallback(async () => { if (!address || !providerRef.current) { @@ -665,10 +602,10 @@ export function useAiCreditsAdapter({ try { const payerAddress = address as Address const existingSession = patchPayerSessionFields(payerAddress) - // Next index = highest derivation index + 1, or 0 if none exist - const nextIndex = existingSession.buyers - .filter((b) => b.type === 'derived' && b.derivationIndex !== undefined) - .reduce((max, b) => Math.max(max, b.derivationIndex ?? 0), -1) + 1 + const nextIndex = + existingSession.buyers + .filter((b) => b.type === 'derived' && b.derivationIndex !== undefined) + .reduce((max, b) => Math.max(max, b.derivationIndex ?? 0), -1) + 1 const message = buildBuyerKeyMessage(payerAddress, nextIndex) const walletClient = createWalletClient({ @@ -700,7 +637,6 @@ export function useAiCreditsAdapter({ buyerPrvKey: privateKey, buyers: updatedSession.buyers, activeBuyerAddress: buyerAccount.address, - // Reset operator consent since this is a new buyer operatorConsented: false, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), @@ -719,6 +655,14 @@ export function useAiCreditsAdapter({ } }, [address]) + /** + * First-buyer entry point used by the purchase flow. + * Delegates to createBuyer so later calls never re-derive index 0 over existing buyers. + */ + const handleGenerateBuyerKey = useCallback(async () => { + await handleCreateBuyer() + }, [handleCreateBuyer]) + /** * Switches the active buyer to an existing one in the session. * Resets operator consent so it is re-verified for the newly selected buyer. diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 2201a4a6..c7368090 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -60,12 +60,13 @@ export interface AiCreditsWidgetAdapterActions { switchChain: () => Promise /** * Creates a new deterministically-derived buyer by signing a wallet message. - * The index is automatically assigned as the next available slot. + * Uses the next free derivation index (index 0 when none exist yet). + * Safe to call after buyers already exist — never re-forces index 0. */ generateBuyerKey: () => Promise /** * Creates an additional deterministic buyer (next derivation index). - * Alias kept separate from `generateBuyerKey` for UI clarity. + * Shares the same implementation as `generateBuyerKey`. */ createBuyer: () => Promise /** From c87072f71dd4f4b78cef509d2f8fa5662176e9be Mon Sep 17 00:00:00 2001 From: blueogin Date: Mon, 3 Aug 2026 10:01:20 -0400 Subject: [PATCH 06/17] refactor: update buyer management in AI Credits widget - Removed the createBuyer function and consolidated its logic into generateBuyerKey for clarity and to streamline buyer key generation. - Renamed buyer variables for consistency, changing labels to 'Wallet buyer' and 'Imported 1' for better clarity in the UI. - Updated the buyer management stories in Storybook to reflect these changes, ensuring accurate representation of buyer types. - Enhanced comments and documentation to clarify the new buyer management flow and its implications for existing functionality. --- .../helpers/aiCreditsWidgetStories.tsx | 51 +++++++++---- packages/ai-credits-widget/src/adapter.ts | 75 +++++++++---------- .../src/buyerKeyDerivation.ts | 10 +-- .../components/manage/BuyerOperatorCard.tsx | 49 ++++-------- .../ai-credits-widget/src/payerSession.ts | 22 +----- .../src/widgetRuntimeContract.ts | 10 +-- .../widgets/ai-credits-widget/states.spec.ts | 11 ++- 7 files changed, 96 insertions(+), 132 deletions(-) diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 58eecfd3..d2b0ef95 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -60,7 +60,6 @@ function createAdapterFactory( connect: async () => {}, switchChain: async () => {}, generateBuyerKey: async () => {}, - createBuyer: async () => {}, selectBuyer: () => {}, importBuyerFromPrivateKey: async () => {}, selectBuyerByAddress: () => {}, @@ -410,16 +409,16 @@ export function InjectedWalletStory() { // Multi-buyer fixture stories // --------------------------------------------------------------------------- -const BUYER_A = { +const BUYER_WALLET = { address: '0xfc128652c9b397a1f89A9EC84E798B869B0E4c7a' as const, privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001' as const, - label: 'Buyer 1', + label: 'Wallet buyer', } -const BUYER_B = { +const BUYER_IMPORTED = { address: '0xAbcDef1234567890AbcDef1234567890AbcDef12' as const, privateKey: '0x0000000000000000000000000000000000000000000000000000000000000002' as const, - label: 'Buyer 2', + label: 'Imported 1', } const BUYER_WATCH = { @@ -427,15 +426,15 @@ const BUYER_WATCH = { label: 'Watch 0x1111…1111', } -/** Multi-buyer manage tab: two derived buyers + one address-only watcher. */ +/** Multi-buyer manage: one wallet-derived buyer, one imported key, one address-only. */ export function MultiBuyerManageStory() { return ( ) @@ -480,16 +489,26 @@ export function MultiBuyerHistoryStory() { dataTestId="AiCreditsWidget-multi-buyer-history" adapterFactory={createAdapterFactory('quote_ready', { totalCreditUsd: '110000000', - buyerPubKey: BUYER_A.address, - buyerPrvKey: BUYER_A.privateKey, + buyerPubKey: BUYER_WALLET.address, + buyerPrvKey: BUYER_WALLET.privateKey, operatorConsented: true, gBalance: '42.50', activeTab: 'history', buyers: [ - { address: BUYER_A.address, privateKey: BUYER_A.privateKey, type: 'derived', derivationIndex: 0, label: BUYER_A.label }, - { address: BUYER_B.address, privateKey: BUYER_B.privateKey, type: 'derived', derivationIndex: 1, label: BUYER_B.label }, + { + address: BUYER_WALLET.address, + privateKey: BUYER_WALLET.privateKey, + type: 'derived', + label: BUYER_WALLET.label, + }, + { + address: BUYER_IMPORTED.address, + privateKey: BUYER_IMPORTED.privateKey, + type: 'imported', + label: BUYER_IMPORTED.label, + }, ], - activeBuyerAddress: BUYER_A.address, + activeBuyerAddress: BUYER_WALLET.address, })} /> ) diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 19991c44..a58ea160 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -339,20 +339,14 @@ function syncOperatorConsentSession(address: string, operatorConsented: boolean patchPayerSession(address, { operatorConsented }) } -/** - * Ensures a buyer derived from the backend account view is reflected in the session. - * Only adds the buyer when no session buyers exist yet (first-time sync). - */ function syncBuyerPubKeySession(address: string, buyerPubKey: string | null | undefined): void { if (!buyerPubKey) return const existing = readPayerSession(address) if (existing?.buyers && existing.buyers.length > 0) return - // Persist as a derived buyer at index 0 (legacy-compatible) addBuyerToSession(address, { address: buyerPubKey, type: 'derived', - derivationIndex: 0, - label: 'Buyer 1', + label: 'Wallet buyer', }) } @@ -584,30 +578,46 @@ export function useAiCreditsAdapter({ }, []) /** - * Creates a derived buyer at the next available derivation index. - * Index 0 preserves the legacy single-buyer message for backward compatibility. + * Creates or restores the single deterministic wallet buyer. + * If that buyer already exists with a private key, it is selected instead of re-derived. */ - const handleCreateBuyer = useCallback(async () => { + const handleGenerateBuyerKey = useCallback(async () => { if (!address || !providerRef.current) { setState((prev) => withDerivedStatus( prev, - { error: 'Connect your wallet before creating a buyer' }, + { error: 'Connect your wallet before generating a buyer key' }, true, ), ) return } + const payerAddress = address as Address + const existingSession = patchPayerSessionFields(payerAddress) + const existingDerived = existingSession.buyers.find((buyer) => buyer.type === 'derived') + + if (existingDerived?.privateKey) { + patchPayerSession(payerAddress, { + activeBuyerAddress: existingDerived.address, + operatorConsented: false, + }) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + buyerPubKey: existingDerived.address, + buyerPrvKey: existingDerived.privateKey ?? null, + buyers: existingSession.buyers, + activeBuyerAddress: existingDerived.address, + operatorConsented: false, + error: null, + ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), + }), + ) + return + } + try { - const payerAddress = address as Address - const existingSession = patchPayerSessionFields(payerAddress) - const nextIndex = - existingSession.buyers - .filter((b) => b.type === 'derived' && b.derivationIndex !== undefined) - .reduce((max, b) => Math.max(max, b.derivationIndex ?? 0), -1) + 1 - - const message = buildBuyerKeyMessage(payerAddress, nextIndex) + const message = buildBuyerKeyMessage(payerAddress) const walletClient = createWalletClient({ account: payerAddress, chain: CELO_CHAIN, @@ -619,14 +629,12 @@ export function useAiCreditsAdapter({ }) const privateKey = deriveBuyerPrivateKeyFromSignature(signature) const buyerAccount = privateKeyToAccount(privateKey) - const label = `Buyer ${nextIndex + 1}` const buyerRecord: BuyerRecord = { address: buyerAccount.address, privateKey, type: 'derived', - derivationIndex: nextIndex, - label, + label: existingDerived?.label ?? 'Wallet buyer', } addBuyerToSession(payerAddress, buyerRecord) @@ -647,7 +655,7 @@ export function useAiCreditsAdapter({ withDerivedStatus( prev, { - error: err instanceof Error ? err.message : 'Buyer creation was rejected', + error: err instanceof Error ? err.message : 'Buyer key generation was rejected', }, true, ), @@ -655,14 +663,6 @@ export function useAiCreditsAdapter({ } }, [address]) - /** - * First-buyer entry point used by the purchase flow. - * Delegates to createBuyer so later calls never re-derive index 0 over existing buyers. - */ - const handleGenerateBuyerKey = useCallback(async () => { - await handleCreateBuyer() - }, [handleCreateBuyer]) - /** * Switches the active buyer to an existing one in the session. * Resets operator consent so it is re-verified for the newly selected buyer. @@ -879,9 +879,6 @@ export function useAiCreditsAdapter({ address: trimmedAddress, type: existingBuyer?.privateKey ? existingBuyer.type : 'address-only', ...(existingBuyer?.privateKey ? { privateKey: existingBuyer.privateKey } : {}), - ...(existingBuyer?.derivationIndex !== undefined - ? { derivationIndex: existingBuyer.derivationIndex } - : {}), label: existingBuyer?.label ?? `Partner ${trimmedAddress.slice(0, 6)}…${trimmedAddress.slice(-4)}`, @@ -1025,9 +1022,7 @@ export function useAiCreditsAdapter({ } let buyerSig: `0x${string}` - if (storedOperatorSignature) { - buyerSig = storedOperatorSignature as `0x${string}` - } else { + if (currentState.buyerPrvKey) { const payload = await chainClient.buildOperatorConsentPayload(ref, operatorStatus) if (!payload.enabled || !payload.typedData) { @@ -1038,6 +1033,10 @@ export function useAiCreditsAdapter({ currentState.buyerPrvKey as `0x${string}`, payload.typedData, ) + } else if (storedOperatorSignature) { + buyerSig = storedOperatorSignature as `0x${string}` + } else { + throw new Error('Generate a buyer key before signing operator consent') } await backendClient.submitOperatorConsent(ref.buyer, { @@ -1582,7 +1581,6 @@ export function useAiCreditsAdapter({ connect: handleConnect, switchChain: handleSwitchChain, generateBuyerKey: handleGenerateBuyerKey, - createBuyer: handleCreateBuyer, selectBuyer: handleSelectBuyer, importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, selectBuyerByAddress: handleSelectBuyerByAddress, @@ -1603,7 +1601,6 @@ export function useAiCreditsAdapter({ handleConnect, handleSwitchChain, handleGenerateBuyerKey, - handleCreateBuyer, handleSelectBuyer, handleImportBuyerFromPrivateKey, handleSelectBuyerByAddress, diff --git a/packages/ai-credits-widget/src/buyerKeyDerivation.ts b/packages/ai-credits-widget/src/buyerKeyDerivation.ts index c5e77461..3c39b0e4 100644 --- a/packages/ai-credits-widget/src/buyerKeyDerivation.ts +++ b/packages/ai-credits-widget/src/buyerKeyDerivation.ts @@ -3,14 +3,8 @@ import { privateKeyToAccount } from 'viem/accounts' const SECP256K1_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n -/** - * Builds the wallet-signature message used to derive a buyer private key. - * Index 0 preserves the legacy single-buyer message for backward compatibility. - * Index > 0 appends the index so each buyer gets a unique deterministic key. - */ -export function buildBuyerKeyMessage(payerAddress: string, buyerIndex = 0): string { - const base = `Generate a key for G$ credits from payer wallet of '${payerAddress.toLowerCase()}'` - return buyerIndex === 0 ? base : `${base} (buyer ${buyerIndex})` +export function buildBuyerKeyMessage(payerAddress: string): string { + return `Generate a key for G$ credits from payer wallet of '${payerAddress.toLowerCase()}'` } export function deriveBuyerPrivateKeyFromSignature(signature: Hex): `0x${string}` { diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index c4ea1d03..fdeac769 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -14,7 +14,6 @@ interface BuyerOperatorCardProps { actions: Pick< AiCreditsWidgetAdapterActions, | 'generateBuyerKey' - | 'createBuyer' | 'selectBuyer' | 'importBuyerFromPrivateKey' | 'selectBuyerByAddress' @@ -22,16 +21,14 @@ interface BuyerOperatorCardProps { > } -/** Displays a short label for a buyer in the selector list. */ function buyerDisplayLabel(buyer: BuyerRecord): string { if (buyer.label) return buyer.label const shortAddr = `${buyer.address.slice(0, 6)}…${buyer.address.slice(-4)}` if (buyer.type === 'address-only') return `Watch ${shortAddr}` if (buyer.type === 'imported') return `Import ${shortAddr}` - return shortAddr + return 'Wallet buyer' } -/** Renders the buyer selection list when multiple buyers are present. */ function BuyerSelector({ buyers, activeBuyerAddress, @@ -72,7 +69,12 @@ function BuyerSelector({ }} > - + {buyerDisplayLabel(buyer)} @@ -93,7 +95,6 @@ function BuyerSelector({ ) } -/** Collapsible panel to import a buyer from a hex private key or address. */ function BuyerImportPanel({ onImportPrivateKey, onSelectAddress, @@ -158,7 +159,7 @@ function BuyerImportPanel({ size="sm" value={inputValue} onChangeText={setInputValue} - placeholder={mode === 'private-key' ? '0x…' : '0x…'} + placeholder="0x…" secureTextEntry={mode === 'private-key'} autoFocus /> @@ -167,7 +168,9 @@ function BuyerImportPanel({ size="sm" {...compactButtonProps} disabled={!inputValue.trim() || isSubmitting} - onPress={() => { void handleSubmit() }} + onPress={() => { + void handleSubmit() + }} > {isSubmitting ? 'Importing…' : 'Confirm'} @@ -190,17 +193,15 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { const { copied: copiedPrivate, copy: copyPrivate } = useCopyFeedback() const [isPrivateKeyVisible, setIsPrivateKeyVisible] = useState(false) const [isGenerating, setIsGenerating] = useState(false) - const [isCreating, setIsCreating] = useState(false) const [isSigning, setIsSigning] = useState(false) const [showImport, setShowImport] = useState(false) - /** The buyer can approve the operator via private key or a stored deep-link signature. */ const activeBuyer = buyers.find( (buyer) => buyer.address.toLowerCase() === (activeBuyerAddress ?? buyerPubKey ?? '').toLowerCase(), ) const buyerCanSign = Boolean(buyerPrvKey || activeBuyer?.operatorSignature) - const hasBuyers = buyers.length > 0 + const hasDerivedBuyer = buyers.some((buyer) => buyer.type === 'derived') return ( @@ -209,17 +210,14 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { {address && } {buyerPubKey && } - {/* Buyer selector — visible only when more than one buyer exists */} - {/* Primary buyer action buttons */} - {!hasBuyers ? ( - // First buyer: sign & generate deterministic key + {!hasDerivedBuyer && ( - ) : ( - // Additional buyer: create next derived buyer - )} - - - ) - } - return ( - {mode === 'private-key' ? 'Paste private key (0x…)' : 'Paste buyer address (0x…)'} + Paste private key (0x…) @@ -179,7 +145,7 @@ function BuyerImportPanel({ variant="outline" {...compactButtonProps} disabled={isSubmitting} - onPress={handleCancel} + onPress={onClose} > Cancel @@ -257,7 +223,7 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { {showImport ? ( setShowImport(false)} /> ) : ( )} diff --git a/packages/ai-credits-widget/src/deepLinkParams.ts b/packages/ai-credits-widget/src/deepLinkParams.ts index 9e2b7417..7a9480d0 100644 --- a/packages/ai-credits-widget/src/deepLinkParams.ts +++ b/packages/ai-credits-widget/src/deepLinkParams.ts @@ -14,7 +14,7 @@ const OPERATOR_SIGNATURE_RE = /^0x[0-9a-fA-F]{128}([0-9a-fA-F]{2})?$/ const DEEP_LINK_STORAGE_KEY = 'goodwidget.ai-credits.deepLink' export const DEEP_LINK_MANUAL_FALLBACK_HINT = - 'Select or import a buyer manually to continue.' + 'Generate or import a buyer key manually to continue.' export function isValidBuyerAddress(value: string): boolean { return BUYER_ADDRESS_RE.test(value.trim()) diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index d5dfbf3b..3dfa0e2f 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -1,10 +1,10 @@ /** Indicates how a buyer identity was created or loaded. */ -export type BuyerIdentityType = 'derived' | 'imported' | 'address-only' +export type BuyerIdentityType = 'derived' | 'imported' | 'deep-link' /** A single buyer identity stored per payer session. */ export type BuyerRecord = { address: string - /** Present for derived/imported buyers; absent for address-only / deep-link. */ + /** Present for derived/imported buyers; absent for deep-link buyers. */ privateKey?: string type: BuyerIdentityType label?: string @@ -96,7 +96,7 @@ export function addBuyerToSession(address: string, buyer: BuyerRecord): void { ...b, ...buyer, privateKey, - type: privateKey ? (buyer.privateKey ? buyer.type : b.type) : 'address-only', + type: privateKey ? (buyer.privateKey ? buyer.type : b.type) : buyer.type ?? b.type, operatorSignature: buyer.operatorSignature ?? b.operatorSignature, } }) diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 27990cd7..8973ff53 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -36,7 +36,7 @@ export interface AiCreditsWidgetAdapterState { isGoodIdVerified: boolean /** Active buyer public address (derived from `buyers` + `activeBuyerAddress`). */ buyerPubKey: string | null - /** Active buyer private key – absent for address-only buyers. */ + /** Active buyer private key – absent for deep-link buyers. */ buyerPrvKey: string | null operatorConsented: boolean operatorAddress: string | null @@ -60,7 +60,7 @@ export interface AiCreditsWidgetAdapterActions { switchChain: () => Promise /** * Creates or restores the single deterministic buyer for this payer wallet. - * Additional buyers come from private-key import or address + operatorSignature. + * Additional buyers come from private-key import or NCDI deep link. */ generateBuyerKey: () => Promise /** @@ -73,15 +73,11 @@ export interface AiCreditsWidgetAdapterActions { * Validates the key format before accepting. */ importBuyerFromPrivateKey: (privateKey: string) => Promise - /** - * Registers a buyer address without a private key (view/consent-pairing mode). - * Sign-required actions will be disabled for this buyer. - */ - selectBuyerByAddress: (address: string) => void /** * Applies an NCDI deep-link buyer assignment from URL GET parameters - * (`buyerAddress` + `operatorSignature`). Submits the pre-signed operator - * approval token and starts the buy flow. Never accepts a buyer private key. + * (`buyerAddress` + `operatorSignature`). Registers a `deep-link` buyer, + * submits the pre-signed operator approval token, and starts the buy flow. + * Never accepts a buyer private key. */ applyDeepLinkBuyer: (address: string, operatorSignature: string) => Promise signOperatorConsent: () => Promise diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts index b446f68f..654fb6ab 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -229,8 +229,8 @@ test('AiCreditsWidget appkit connect wallet opens modal', async ({ page }) => { const MULTI_BUYER_STORY_IDS = { multiBuyerManage: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-manage&viewMode=story', - addressOnlyBuyer: - '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--address-only-buyer&viewMode=story', + deepLinkBuyer: + '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--deep-link-buyer&viewMode=story', multiBuyerHistory: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-history&viewMode=story', } as const @@ -242,7 +242,7 @@ test('AiCreditsWidget multi-buyer manage: buyer selector is visible', async ({ p await expect(root.getByText('Wallet buyer')).toBeVisible() await expect(root.getByText('Imported 1')).toBeVisible() - await expect(root.getByText(/Watch 0x1111/i)).toBeVisible() + await expect(root.getByText(/Partner 0x1111/i)).toBeVisible() await expect(root.getByRole('button', { name: /New Buyer/i })).toHaveCount(0) await expect(root.getByRole('button', { name: /Sign & Generate/i })).toHaveCount(0) @@ -252,17 +252,18 @@ test('AiCreditsWidget multi-buyer manage: buyer selector is visible', async ({ p }) }) -test('AiCreditsWidget address-only buyer: sign-required actions disabled', async ({ page }) => { - await gotoStory(page, MULTI_BUYER_STORY_IDS.addressOnlyBuyer) - const root = widget(page, 'AiCreditsWidget-address-only-buyer') +test('AiCreditsWidget deep-link buyer: Sign Consent enabled via operatorSignature', async ({ + page, +}) => { + await gotoStory(page, MULTI_BUYER_STORY_IDS.deepLinkBuyer) + const root = widget(page, 'AiCreditsWidget-deep-link-buyer') await expect(root).toBeVisible() - // Sign Consent button should be disabled for address-only buyer const signConsentButton = root.getByRole('button', { name: /Sign Consent/i }) - await expect(signConsentButton).toBeDisabled() + await expect(signConsentButton).toBeEnabled() await page.screenshot({ - path: 'tests/widgets/ai-credits-widget/test-results/acw-16-address-only-buyer.png', + path: 'tests/widgets/ai-credits-widget/test-results/acw-16-deep-link-buyer.png', fullPage: true, }) }) @@ -272,7 +273,6 @@ test('AiCreditsWidget multi-buyer history: buyer filter dropdown is visible', as const root = widget(page, 'AiCreditsWidget-multi-buyer-history') await expect(root).toBeVisible() - // History tab should show a buyer filter that starts with "All buyers" await expect(root.getByText(/Buyer: All buyers/i)).toBeVisible() await page.screenshot({ @@ -281,16 +281,16 @@ test('AiCreditsWidget multi-buyer history: buyer filter dropdown is visible', as }) }) -test('AiCreditsWidget multi-buyer: import or watch link is visible', async ({ page }) => { +test('AiCreditsWidget multi-buyer: import buyer key link is visible', async ({ page }) => { await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerManage) const root = widget(page, 'AiCreditsWidget-multi-buyer-manage') await expect(root).toBeVisible() - // Import/watch link should always be available in the Buyer & Operator card - await expect(root.getByText(/Import or watch a buyer/i)).toBeVisible() + await expect(root.getByText(/Import a buyer key/i)).toBeVisible() + await expect(root.getByText(/Watch Address/i)).toHaveCount(0) await page.screenshot({ - path: 'tests/widgets/ai-credits-widget/test-results/acw-18-import-watch-link.png', + path: 'tests/widgets/ai-credits-widget/test-results/acw-18-import-key-link.png', fullPage: true, }) }) From 1fad04528494dc0a2a117fb67fda4f096fcebb3f Mon Sep 17 00:00:00 2001 From: blueogin Date: Mon, 3 Aug 2026 12:27:13 -0400 Subject: [PATCH 08/17] refactor: enhance buyer management and state handling in AI Credits widget - Introduced operator signature handling in buyer management, allowing for improved deep-link buyer registration. - Updated the state structure to accommodate new buyer key entries and streamlined the buyer selection process. - Modified relevant components and stories in Storybook to reflect changes in buyer management, ensuring accurate representation of buyer types. - Enhanced comments and documentation for clarity on the new buyer management flow and its implications for existing functionality. --- .../helpers/aiCreditsWidgetStories.tsx | 52 +-- .../ai-credits-widget/src/AiCreditsWidget.tsx | 2 +- packages/ai-credits-widget/src/adapter.ts | 371 ++++++++---------- .../ai-credits-widget/src/backendClient.ts | 42 +- .../ai-credits-widget/src/backendTypes.ts | 1 + .../components/buy/OperatorConsentStep.tsx | 4 +- .../components/flow/AiCreditsPurchaseFlow.tsx | 1 + .../src/components/flow/purchaseFlowUtils.ts | 5 +- .../components/manage/BuyerOperatorCard.tsx | 83 ++-- packages/ai-credits-widget/src/index.ts | 4 +- .../ai-credits-widget/src/payerSession.ts | 328 +++++++++++----- .../src/widgetRuntimeContract.ts | 22 +- .../widgets/ai-credits-widget/states.spec.ts | 9 +- 13 files changed, 504 insertions(+), 420 deletions(-) diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 29d3104c..5b7fc65c 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -33,6 +33,7 @@ function createMockState( isGoodIdVerified: false, buyerPubKey: null, buyerPrvKey: null, + operatorSignature: null, operatorConsented: false, operatorAddress: null, minDepositUsd: '1.00', @@ -411,23 +412,20 @@ export function InjectedWalletStory() { const BUYER_WALLET = { address: '0xfc128652c9b397a1f89A9EC84E798B869B0E4c7a' as const, privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001' as const, - label: 'Wallet buyer', } const BUYER_IMPORTED = { address: '0xAbcDef1234567890AbcDef1234567890AbcDef12' as const, privateKey: '0x0000000000000000000000000000000000000000000000000000000000000002' as const, - label: 'Imported 1', } const BUYER_PARTNER = { address: '0x1111111111111111111111111111111111111111' as const, - label: 'Partner 0x1111…1111', operatorSignature: '0x1111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200' as const, } -/** Multi-buyer manage: one wallet-derived buyer, one imported key, one deep-link partner. */ +/** Multi-buyer manage: backend address list with one selected buyer that has a local key. */ export function MultiBuyerManageStory() { return ( @@ -476,17 +455,11 @@ export function DeepLinkBuyerStory() { adapterFactory={createAdapterFactory('purchase_setup', { buyerPubKey: BUYER_PARTNER.address, buyerPrvKey: null, + operatorSignature: BUYER_PARTNER.operatorSignature, operatorConsented: false, gBalance: '42.50', activeTab: 'manage', - buyers: [ - { - address: BUYER_PARTNER.address, - type: 'deep-link', - label: BUYER_PARTNER.label, - operatorSignature: BUYER_PARTNER.operatorSignature, - }, - ], + buyers: [BUYER_PARTNER.address], activeBuyerAddress: BUYER_PARTNER.address, })} /> @@ -505,20 +478,7 @@ export function MultiBuyerHistoryStory() { operatorConsented: true, gBalance: '42.50', activeTab: 'history', - buyers: [ - { - address: BUYER_WALLET.address, - privateKey: BUYER_WALLET.privateKey, - type: 'derived', - label: BUYER_WALLET.label, - }, - { - address: BUYER_IMPORTED.address, - privateKey: BUYER_IMPORTED.privateKey, - type: 'imported', - label: BUYER_IMPORTED.label, - }, - ], + buyers: [BUYER_WALLET.address, BUYER_IMPORTED.address], activeBuyerAddress: BUYER_WALLET.address, })} /> diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index 4db22b52..efac7d71 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -393,7 +393,7 @@ function AiCreditsInner({ ({ address: b.address, label: b.label }))} + knownBuyers={state.buyers.map((address) => ({ address }))} /> ) : ( buyPanel diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 13048a14..65c34d2d 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -46,9 +46,12 @@ import { patchPayerSessionFields, patchPayerSession, readPayerSession, - addBuyerToSession, + upsertBuyerKey, + setActiveBuyerAddress, + mergeBuyerAddressList, + normalizeBuyerAddressList, + getBuyerKeyEntry, } from './payerSession' -import type { BuyerRecord } from './payerSession' import { executeCeloPayment, G_TOKEN_CELO_ADDRESS, isStreamAmountChanged } from './celoPayment' import { startGoodIdVerification, isUserRejectedWalletRequest } from './goodIdVerification' import { mapPaymentError } from './paymentErrors' @@ -95,6 +98,7 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { isGoodIdVerified: false, buyerPubKey: null, buyerPrvKey: null, + operatorSignature: null, operatorConsented: false, operatorAddress: null, minDepositUsd: null, @@ -297,40 +301,44 @@ function viewToStatePatch( withdrawableUsd: view.withdrawableUsd, totalGdDepositedG: enriched.totalGdDepositedG, monthlyStreamG: enriched.monthlyStreamG, - ...(view.buyer ? { buyerPubKey: view.buyer } : {}), } } -function mergeSessionFields( - prev: AiCreditsWidgetAdapterState, - sessionPatch: ReturnType, - accountPatch: Partial, - accountSwitched: boolean, -): Partial< - Pick< - AiCreditsWidgetAdapterState, - 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' | 'buyers' | 'activeBuyerAddress' - > +function buyerSelectionFields( + payer: string, + buyers: string[], + selectedAddress: string | null, +): Pick< + AiCreditsWidgetAdapterState, + | 'buyers' + | 'activeBuyerAddress' + | 'buyerPubKey' + | 'buyerPrvKey' + | 'operatorSignature' + | 'operatorConsented' > { - const buyerPubKey = - sessionPatch.buyerPubKey ?? - accountPatch.buyerPubKey ?? - (accountSwitched ? null : prev.buyerPubKey) - const buyerPrvKey = sessionPatch.buyerPrvKey ?? (accountSwitched ? null : prev.buyerPrvKey) - const operatorConsented = accountSwitched - ? (sessionPatch.operatorConsented ?? accountPatch.operatorConsented ?? false) - : (accountPatch.operatorConsented ?? sessionPatch.operatorConsented ?? prev.operatorConsented) - const buyers = accountSwitched ? sessionPatch.buyers : (sessionPatch.buyers.length > 0 ? sessionPatch.buyers : prev.buyers) - const activeBuyerAddress = accountSwitched - ? (sessionPatch.activeBuyerAddress ?? null) - : (sessionPatch.activeBuyerAddress ?? prev.activeBuyerAddress) + const selected = selectedAddress + const existing = readPayerSession(payer) + const alreadyActive = + !!selected && + !!existing?.activeBuyerAddress && + existing.activeBuyerAddress.toLowerCase() === selected.toLowerCase() + + if (selected && !alreadyActive) { + setActiveBuyerAddress(payer, selected) + } else if (!selected && existing?.activeBuyerAddress) { + setActiveBuyerAddress(payer, null) + } + const session = patchPayerSessionFields(payer) + const entry = selected ? getBuyerKeyEntry(payer, selected) : null return { - buyerPubKey, - buyerPrvKey, - operatorConsented, buyers, - activeBuyerAddress, + activeBuyerAddress: selected, + buyerPubKey: selected, + buyerPrvKey: entry?.privateKey ?? null, + operatorSignature: entry?.operatorSignature ?? null, + operatorConsented: selected ? session.operatorConsented : false, } } @@ -339,17 +347,6 @@ function syncOperatorConsentSession(address: string, operatorConsented: boolean patchPayerSession(address, { operatorConsented }) } -function syncBuyerPubKeySession(address: string, buyerPubKey: string | null | undefined): void { - if (!buyerPubKey) return - const existing = readPayerSession(address) - if (existing?.buyers && existing.buyers.length > 0) return - addBuyerToSession(address, { - address: buyerPubKey, - type: 'derived', - label: 'Wallet buyer', - }) -} - export interface UseAiCreditsAdapterOptions { environment?: AiCreditsWidgetEnvironment backendUrl?: string @@ -381,6 +378,9 @@ export function useAiCreditsAdapter({ const providerRef = useRef(null) providerRef.current = provider as EIP1193Provider | null const goodIdVerifyPendingRef = useRef(false) + const pendingDeepLinkRef = useRef(null) + const deepLinkParseDoneRef = useRef(false) + const deepLinkApplyInFlightRef = useRef(false) const celoVault = vaultAddress ?? CELO_GD_ANTSEED_VAULT_FALLBACK @@ -419,14 +419,17 @@ export function useAiCreditsAdapter({ ) { return prev } - const accountSwitched = !addressesMatch(prev.address, address) - const buyerFields = mergeSessionFields(prev, sessionPatch, {}, accountSwitched) return withDerivedStatus( prev, { address, chainId, - ...buyerFields, + buyerPubKey: sessionPatch.buyerPubKey, + buyerPrvKey: sessionPatch.buyerPrvKey, + operatorSignature: sessionPatch.operatorSignature, + operatorConsented: sessionPatch.operatorConsented, + activeBuyerAddress: sessionPatch.activeBuyerAddress, + buyers: prev.buyers, ...WALLET_LOADING_STATE, error: null, status: 'connecting', @@ -452,7 +455,7 @@ export function useAiCreditsAdapter({ ]) const accountPromise = buildAccountView(address!, backendClient, chainClient, { - buyerAddress: sessionPatch.buyerPubKey ?? null, + buyerAddress: pendingDeepLinkRef.current?.buyerAddress ?? null, }) .then(async (view) => ({ view, @@ -496,18 +499,28 @@ export function useAiCreditsAdapter({ } setState((prev) => { - const accountSwitched = !addressesMatch(prev.address, address) const accountPatch = account ? viewToStatePatch(account.view, account.enriched, prev, { balanceMode: 'always', }) : {} - const buyerFields = mergeSessionFields(prev, sessionPatch, accountPatch, accountSwitched) - if (address && accountPatch.operatorConsented !== undefined) { - syncOperatorConsentSession(address, accountPatch.operatorConsented) + const backendBuyers = account + ? normalizeBuyerAddressList(account.view.profile.buyers) + : [] + const pendingDeepLink = pendingDeepLinkRef.current + const selectedBuyer = pendingDeepLink?.buyerAddress ?? backendBuyers[0] ?? null + const buyers = mergeBuyerAddressList(backendBuyers, pendingDeepLink?.buyerAddress) + const buyerFields = buyerSelectionFields(address!, buyers, selectedBuyer) + if (pendingDeepLink?.operatorSignature && selectedBuyer) { + upsertBuyerKey(address!, selectedBuyer, { + operatorSignature: pendingDeepLink.operatorSignature, + }) + buyerFields.operatorSignature = pendingDeepLink.operatorSignature + buyerFields.buyerPrvKey = + getBuyerKeyEntry(address!, selectedBuyer)?.privateKey ?? null } - if (address && account?.view.buyer) { - syncBuyerPubKeySession(address, account.view.buyer) + if (address && accountPatch.operatorConsented !== undefined && selectedBuyer) { + syncOperatorConsentSession(address, accountPatch.operatorConsented) } return withDerivedStatus( prev, @@ -523,15 +536,17 @@ export function useAiCreditsAdapter({ } catch { if (cancelled) return setState((prev) => { - const accountSwitched = !addressesMatch(prev.address, address) - const buyerFields = mergeSessionFields(prev, sessionPatch, {}, accountSwitched) return withDerivedStatus( prev, { address, chainId, gBalance: '0', - ...buyerFields, + buyers: [], + activeBuyerAddress: null, + buyerPubKey: null, + buyerPrvKey: null, + operatorSignature: null, status: chainId !== null && chainId !== CELO_CHAIN_ID ? 'unsupported_chain' @@ -594,25 +609,19 @@ export function useAiCreditsAdapter({ } const payerAddress = address as Address - const existingSession = patchPayerSessionFields(payerAddress) - const existingDerived = existingSession.buyers.find((buyer) => buyer.type === 'derived') - - if (existingDerived?.privateKey) { - patchPayerSession(payerAddress, { - activeBuyerAddress: existingDerived.address, - operatorConsented: false, - }) - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: existingDerived.address, - buyerPrvKey: existingDerived.privateKey ?? null, - buyers: existingSession.buyers, - activeBuyerAddress: existingDerived.address, - operatorConsented: false, + const session = readPayerSession(payerAddress) + const derivedAddress = session?.derivedBuyerAddress ?? null + const existingKey = derivedAddress ? getBuyerKeyEntry(payerAddress, derivedAddress) : null + + if (derivedAddress && existingKey?.privateKey) { + setState((prev) => { + const buyers = mergeBuyerAddressList(prev.buyers, derivedAddress) + return mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(payerAddress, buyers, derivedAddress), error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }), - ) + }) + }) return } @@ -630,26 +639,21 @@ export function useAiCreditsAdapter({ const privateKey = deriveBuyerPrivateKeyFromSignature(signature) const buyerAccount = privateKeyToAccount(privateKey) - const buyerRecord: BuyerRecord = { - address: buyerAccount.address, - privateKey, - type: 'derived', - label: existingDerived?.label ?? 'Wallet buyer', - } - addBuyerToSession(payerAddress, buyerRecord) + upsertBuyerKey( + payerAddress, + buyerAccount.address, + { privateKey }, + { setActive: true, setDerived: true }, + ) - const updatedSession = patchPayerSessionFields(payerAddress) - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: buyerAccount.address, - buyerPrvKey: privateKey, - buyers: updatedSession.buyers, - activeBuyerAddress: buyerAccount.address, - operatorConsented: false, + setState((prev) => { + const buyers = mergeBuyerAddressList(prev.buyers, buyerAccount.address) + return mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(payerAddress, buyers, buyerAccount.address), error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }), - ) + }) + }) } catch (err: unknown) { setState((prev) => withDerivedStatus( @@ -663,45 +667,27 @@ export function useAiCreditsAdapter({ } }, [address]) - /** - * Switches the active buyer to an existing one in the session. - * Resets operator consent so it is re-verified for the newly selected buyer. - */ const handleSelectBuyer = useCallback( (buyerAddress: string) => { if (!address) return - const session = patchPayerSessionFields(address) - const target = session.buyers.find( - (b) => b.address.toLowerCase() === buyerAddress.toLowerCase(), - ) - if (!target) return - - patchPayerSession(address, { activeBuyerAddress: target.address, operatorConsented: false }) - - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: target.address, - buyerPrvKey: target.privateKey ?? null, - buyers: session.buyers, - activeBuyerAddress: target.address, - // Reset consent and chain-loaded data for the newly selected buyer - operatorConsented: false, + setState((prev) => { + if (!prev.buyers.some((item) => item.toLowerCase() === buyerAddress.toLowerCase())) { + return prev + } + return mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(address, prev.buyers, buyerAddress), operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, totalGdDepositedG: null, monthlyStreamG: null, error: null, - }), - ) + }) + }) }, [address], ) - /** - * Imports a buyer identity from a hex-encoded private key string. - * Validates the key format strictly before accepting. - */ const handleImportBuyerFromPrivateKey = useCallback( async (rawPrivateKey: string) => { if (!address) { @@ -727,25 +713,12 @@ export function useAiCreditsAdapter({ try { const privateKey = normalized as `0x${string}` const buyerAccount = privateKeyToAccount(privateKey) - const existingSession = patchPayerSessionFields(address) - const label = `Imported ${existingSession.buyers.filter((b) => b.type === 'imported').length + 1}` - - const buyerRecord: BuyerRecord = { - address: buyerAccount.address, - privateKey, - type: 'imported', - label, - } - addBuyerToSession(address, buyerRecord) + upsertBuyerKey(address, buyerAccount.address, { privateKey }, { setActive: true }) - const updatedSession = patchPayerSessionFields(address) - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: buyerAccount.address, - buyerPrvKey: privateKey, - buyers: updatedSession.buyers, - activeBuyerAddress: buyerAccount.address, - operatorConsented: false, + setState((prev) => { + const buyers = mergeBuyerAddressList(prev.buyers, buyerAccount.address) + return mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(address, buyers, buyerAccount.address), operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -753,8 +726,8 @@ export function useAiCreditsAdapter({ monthlyStreamG: null, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }), - ) + }) + }) } catch { setState((prev) => withDerivedStatus(prev, { error: 'Could not derive an account from the provided private key' }, true), @@ -764,6 +737,21 @@ export function useAiCreditsAdapter({ [address], ) + const reloadBuyerAddresses = useCallback( + async (payer: string, preferredBuyer?: string | null) => { + const backendBuyers = await backendClient.getBuyerAddresses(payer) + const preferredStillPresent = + !!preferredBuyer && + backendBuyers.some((item) => item.toLowerCase() === preferredBuyer.toLowerCase()) + const selected = preferredStillPresent + ? preferredBuyer + : backendBuyers[0] ?? preferredBuyer ?? null + const buyers = mergeBuyerAddressList(backendBuyers, preferredBuyer) + return { buyers, selected } + }, + [backendClient], + ) + /** * Registers a buyer from an NCDI deep link and submits the pre-signed * operator-approval token. Never stores a buyer private key from the URL. @@ -812,36 +800,17 @@ export function useAiCreditsAdapter({ operatorSignature: trimmedSignature, }) - const existingSession = patchPayerSessionFields(address) - const existingBuyer = existingSession.buyers.find( - (b) => b.address.toLowerCase() === trimmedAddress.toLowerCase(), + upsertBuyerKey( + address, + trimmedAddress, + { operatorSignature: trimmedSignature }, + { setActive: true }, ) - const buyerRecord: BuyerRecord = { - address: trimmedAddress, - type: existingBuyer?.privateKey ? existingBuyer.type : 'deep-link', - ...(existingBuyer?.privateKey ? { privateKey: existingBuyer.privateKey } : {}), - label: - existingBuyer?.label ?? - `Partner ${trimmedAddress.slice(0, 6)}…${trimmedAddress.slice(-4)}`, - operatorSignature: trimmedSignature, - } - - addBuyerToSession(address, buyerRecord) - patchPayerSession(address, { - activeBuyerAddress: trimmedAddress, - operatorSignature: trimmedSignature, - operatorConsented: false, - }) - - const updatedSession = patchPayerSessionFields(address) - setState((prev) => - mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: trimmedAddress, - buyerPrvKey: existingBuyer?.privateKey ?? null, - buyers: updatedSession.buyers, - activeBuyerAddress: trimmedAddress, - operatorConsented: false, + setState((prev) => { + const buyers = mergeBuyerAddressList(prev.buyers, trimmedAddress) + return mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(address, buyers, trimmedAddress), operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -849,8 +818,8 @@ export function useAiCreditsAdapter({ monthlyStreamG: null, activeTab: 'buy', error: null, - }), - ) + }) + }) const ref: AccountRef = { payer: address, buyer: trimmedAddress } @@ -865,22 +834,18 @@ export function useAiCreditsAdapter({ await backendClient.submitOperatorConsent(ref.buyer, { nonce: operatorStatus.consentNonce, signature: trimmedSignature, + payer: address, }) await waitForOperatorConsent(chainClient, ref) } - patchPayerSession(address, { - operatorConsented: true, - operatorSignature: trimmedSignature, - }) + const { buyers, selected } = await reloadBuyerAddresses(address, trimmedAddress) + patchPayerSession(address, { operatorConsented: true }) setState((prev) => withDerivedStatus( prev, { - buyerPubKey: trimmedAddress, - buyerPrvKey: existingBuyer?.privateKey ?? null, - buyers: updatedSession.buyers, - activeBuyerAddress: trimmedAddress, + ...buyerSelectionFields(address, buyers, selected), operatorConsented: true, activeTab: 'buy', error: null, @@ -907,7 +872,7 @@ export function useAiCreditsAdapter({ ) } }, - [address, backendClient, chainClient], + [address, backendClient, chainClient, reloadBuyerAddresses], ) const handleSignOperatorConsent = useCallback(async () => { @@ -923,12 +888,9 @@ export function useAiCreditsAdapter({ return } - const session = readPayerSession(currentState.address) - const activeBuyer = session?.buyers.find( - (b) => b.address.toLowerCase() === currentState.buyerPubKey!.toLowerCase(), - ) + const keyEntry = getBuyerKeyEntry(currentState.address, currentState.buyerPubKey) const storedOperatorSignature = - activeBuyer?.operatorSignature ?? session?.operatorSignature ?? null + currentState.operatorSignature ?? keyEntry?.operatorSignature ?? null if (!currentState.buyerPrvKey && !storedOperatorSignature) { setState((prev) => @@ -952,9 +914,14 @@ export function useAiCreditsAdapter({ } if (operatorStatus.operatorAccepted) { + const { buyers, selected } = await reloadBuyerAddresses( + currentState.address, + currentState.buyerPubKey, + ) patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(currentState.address!, buyers, selected), operatorConsented: true, error: null, ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), @@ -984,12 +951,18 @@ export function useAiCreditsAdapter({ await backendClient.submitOperatorConsent(ref.buyer, { nonce: operatorStatus.consentNonce, signature: buyerSig, + payer: currentState.address, }) await waitForOperatorConsent(chainClient, ref) + const { buyers, selected } = await reloadBuyerAddresses( + currentState.address, + currentState.buyerPubKey, + ) patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => mergeStatePreservingNonBuyTab(prev, { + ...buyerSelectionFields(currentState.address!, buyers, selected), operatorConsented: true, error: null, ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), @@ -1001,7 +974,7 @@ export function useAiCreditsAdapter({ error: err instanceof Error ? err.message : 'Operator consent signature rejected', })) } - }, [state, backendClient, chainClient]) + }, [state, backendClient, chainClient, reloadBuyerAddresses]) const handleSyncOperatorConsentFromChain = useCallback(async () => { const currentState = state @@ -1172,8 +1145,18 @@ export function useAiCreditsAdapter({ const creditUsdMicro = (BigInt(totalCreditUsd) - BigInt(balanceBefore || '0')).toString() + const buyerList = await reloadBuyerAddresses( + currentState.address, + currentState.buyerPubKey, + ) + setState((prev) => withDerivedStatus(prev, { + ...buyerSelectionFields( + currentState.address!, + buyerList.buyers, + buyerList.selected, + ), totalCreditUsd, error: null, activeTab: 'manage', @@ -1202,7 +1185,7 @@ export function useAiCreditsAdapter({ throw new Error(message) } }, - [state, backendClient, chainClient, celoVault, onPaySuccess, onPayError], + [state, backendClient, chainClient, celoVault, onPaySuccess, onPayError, reloadBuyerAddresses], ) const handleRefresh = useCallback( @@ -1211,15 +1194,13 @@ export function useAiCreditsAdapter({ if (!currentState.address) return try { - const sessionBuyer = - currentState.buyerPubKey ?? - patchPayerSessionFields(currentState.address).buyerPubKey ?? - null - const [view, discountConfig] = await Promise.all([ + const preferredBuyer = currentState.buyerPubKey + const [view, discountConfig, buyerList] = await Promise.all([ buildAccountView(currentState.address, backendClient, chainClient, { - buyerAddress: sessionBuyer, + buyerAddress: preferredBuyer, }), backendClient.getDiscountConfig().catch(() => null), + reloadBuyerAddresses(currentState.address, preferredBuyer), ]) const enriched = await enrichAccountView(view, chainClient) @@ -1227,18 +1208,14 @@ export function useAiCreditsAdapter({ const accountPatch = viewToStatePatch(view, enriched, prev, { balanceMode: 'always', }) - const sessionFields = mergeSessionFields( - prev, - patchPayerSessionFields(currentState.address), - accountPatch, - false, + const buyerFields = buyerSelectionFields( + currentState.address!, + buyerList.buyers, + buyerList.selected, ) if (accountPatch.operatorConsented !== undefined && currentState.address) { syncOperatorConsentSession(currentState.address, accountPatch.operatorConsented) } - if (currentState.address && view.buyer) { - syncBuyerPubKeySession(currentState.address, view.buyer) - } const statusSeed = options?.afterGoodIdVerify && prev.status === 'payment_failed' ? 'quote_ready' @@ -1247,7 +1224,7 @@ export function useAiCreditsAdapter({ { ...prev, status: statusSeed }, { ...accountPatch, - ...sessionFields, + ...buyerFields, activeTab: prev.activeTab, error: null, depositBonusPercent: @@ -1266,7 +1243,7 @@ export function useAiCreditsAdapter({ ) } }, - [state, backendClient, chainClient], + [state, backendClient, chainClient, reloadBuyerAddresses], ) const handleVerifyGoodId = useCallback(async (): Promise => { @@ -1461,10 +1438,6 @@ export function useAiCreditsAdapter({ handleSetActiveTab('buy') }, [handleSetActiveTab]) - const pendingDeepLinkRef = useRef(null) - const deepLinkParseDoneRef = useRef(false) - const deepLinkApplyInFlightRef = useRef(false) - useEffect(() => { if (typeof window === 'undefined' || deepLinkParseDoneRef.current) return deepLinkParseDoneRef.current = true diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index 7d7c0871..4911af0a 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -81,11 +81,14 @@ export type WithdrawPrincipalResponse = { export type OperatorConsentRequest = { nonce: string signature: string + payer: string } export type OperatorConsentResponse = { buyer: string + payer?: string bridge: BridgeResponse + buyers?: Array<{ address: string; consentedAt?: string }> } async function readBridgeResponseBody( @@ -296,6 +299,7 @@ export async function enrichAccountView( export interface AiCreditsBackendClient { getDiscountConfig(): Promise getAccountCredit(payer: string): Promise + getBuyerAddresses(payer: string): Promise getCreditHistory(payer: string, options?: CreditHistoryQuery): Promise getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }> getTransactions( @@ -362,6 +366,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { bonusUsd: bigint transactions: GdCreditEntry[] rootAccount: string + buyers: Array<{ address: string; consentedAt: string }> } >() @@ -373,6 +378,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { bonusUsd: 0n, transactions: createDemoHistory(key), rootAccount: key, + buyers: [], }) } return this.accountStates.get(key)! @@ -400,6 +406,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { totalGDStreamedWei: '0', totalOutstandingFundingUsd: outstanding.toString(), streamFlowRateWeiPerSecond: '0', + buyers: state.buyers, } } @@ -412,6 +419,11 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { } } + async getBuyerAddresses(payer: string): Promise { + await sleep(MOCK_DELAY_MS) + return this.getState(payer).buyers.map((buyer) => buyer.address) + } + async getCreditHistory( payer: string, options: CreditHistoryQuery = {}, @@ -527,14 +539,24 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { async submitOperatorConsent( buyer: string, - _body: OperatorConsentRequest, + body: OperatorConsentRequest, ): Promise { await sleep(MOCK_DELAY_MS) const normalizedBuyer = normalizeAddress(buyer) + const normalizedPayer = normalizeAddress(body.payer) markMockOperatorConsent(normalizedBuyer) + const state = this.getState(normalizedPayer) + if (!state.buyers.some((item) => item.address === normalizedBuyer)) { + state.buyers = [ + ...state.buyers, + { address: normalizedBuyer, consentedAt: new Date().toISOString() }, + ] + } return { buyer: normalizedBuyer, + payer: normalizedPayer, bridge: { enabled: true, txHash: '0xmock' }, + buyers: state.buyers, } } } @@ -563,6 +585,15 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient return response.json() as Promise } + async getBuyerAddresses(payer: string): Promise { + const response = await fetch(`${this.accountBase(payer)}/buyers`) + if (!response.ok) throw new Error(`Buyer list request failed: ${response.status}`) + const payload = (await response.json()) as { account?: string; buyers?: string[] } + return Array.isArray(payload.buyers) + ? payload.buyers.map((buyer) => normalizeAddress(buyer)) + : [] + } + async getCreditHistory( payer: string, options: CreditHistoryQuery = {}, @@ -700,12 +731,15 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient body: JSON.stringify({ nonce: body.nonce, signature: body.signature, + payer: normalizeAddress(body.payer), }), }) const payload = await readBridgeResponseBody(response, 'Operator consent') return { buyer: normalizeAddress(payload.buyer ?? buyer), + payer: payload.payer ? normalizeAddress(payload.payer) : normalizeAddress(body.payer), bridge: payload.bridge, + buyers: payload.buyers, } } } @@ -737,16 +771,16 @@ export async function buildAccountView( options: BuildAccountViewOptions = {}, ): Promise { const normalizedPayer = normalizeAddress(payer) - const [credit, outstanding, history] = await Promise.all([ + const [credit, outstanding] = await Promise.all([ backend.getAccountCredit(payer), backend.getOutstanding(payer), - backend.getCreditHistory(payer, { limit: MAX_HISTORY_LIMIT, offset: 0 }), ]) + const profileBuyers = (credit.profile.buyers ?? []).map((buyer) => normalizeAddress(buyer.address)) const sessionBuyer = options.buyerAddress && isAddress(options.buyerAddress) ? normalizeAddress(options.buyerAddress) : null - const buyer = sessionBuyer ?? resolveBuyerAddress(history.items) + const buyer = sessionBuyer ?? profileBuyers[0] ?? null const [operator, withdrawableUsd] = buyer ? await Promise.all([ chain.getBuyerOperatorStatus({ payer: normalizedPayer, buyer }), diff --git a/packages/ai-credits-widget/src/backendTypes.ts b/packages/ai-credits-widget/src/backendTypes.ts index 7aee006b..932ef46d 100644 --- a/packages/ai-credits-widget/src/backendTypes.ts +++ b/packages/ai-credits-widget/src/backendTypes.ts @@ -17,6 +17,7 @@ export type UserCreditProfile = { totalOutstandingFundingUsd: string streamFlowRateWeiPerSecond: string lastStreamCreditAt?: string + buyers?: Array<{ address: string; consentedAt?: string }> } export type GdCreditEntry = { diff --git a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx index 0e9dfec9..9b88e6a7 100644 --- a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx +++ b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx @@ -5,6 +5,7 @@ import { truncateAddress, compactButtonProps } from '../shared/styles' interface OperatorConsentStepProps { buyerPubKey: string | null buyerPrvKey: string | null + operatorSignature?: string | null operatorConsented: boolean onSign: () => Promise embedded?: boolean @@ -13,12 +14,13 @@ interface OperatorConsentStepProps { export function OperatorConsentStep({ buyerPubKey, buyerPrvKey, + operatorSignature = null, operatorConsented, onSign, embedded = false, }: OperatorConsentStepProps) { const [isSigning, setIsSigning] = useState(false) - const canSign = Boolean(buyerPubKey && buyerPrvKey) + const canSign = Boolean(buyerPubKey && (buyerPrvKey || operatorSignature)) const Shell = embedded ? YStack : Card diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx index 001ddbee..41546626 100644 --- a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx +++ b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx @@ -129,6 +129,7 @@ export function AiCreditsPurchaseFlow({ embedded buyerPubKey={state.buyerPubKey} buyerPrvKey={state.buyerPrvKey ?? null} + operatorSignature={state.operatorSignature ?? null} operatorConsented={state.operatorConsented} onSign={actions.signOperatorConsent} /> diff --git a/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts b/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts index 51d809f9..3f9726cd 100644 --- a/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts +++ b/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts @@ -6,8 +6,9 @@ export function mapStatusToActiveStep( buyerPubKeySaved: boolean, ): AiCreditsFlowStep | null { if (state.operatorConsented) return 'pay' - if (!state.buyerPubKey || !state.buyerPrvKey) return 'buyer_key' - if (!buyerPubKeySaved) return 'buyer_key' + if (!state.buyerPubKey) return 'buyer_key' + if (!state.buyerPrvKey && !state.operatorSignature) return 'buyer_key' + if (state.buyerPrvKey && !buyerPubKeySaved) return 'buyer_key' if (!state.operatorConsented) return 'consent' if ( state.status === 'purchase_setup' || diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index 2927cdc9..a7a0a433 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react' import { Button, ButtonText, Card, Heading, Icon, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract' -import type { BuyerRecord } from '../../payerSession' import { AddressView } from '../shared/AddressView' import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles' import { useCopyFeedback } from '../shared/useCopyFeedback' @@ -9,7 +8,13 @@ import { useCopyFeedback } from '../shared/useCopyFeedback' interface BuyerOperatorCardProps { state: Pick< AiCreditsWidgetAdapterState, - 'address' | 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' | 'buyers' | 'activeBuyerAddress' + | 'address' + | 'buyerPubKey' + | 'buyerPrvKey' + | 'operatorSignature' + | 'operatorConsented' + | 'buyers' + | 'activeBuyerAddress' > actions: Pick< AiCreditsWidgetAdapterActions, @@ -20,12 +25,8 @@ interface BuyerOperatorCardProps { > } -function buyerDisplayLabel(buyer: BuyerRecord): string { - if (buyer.label) return buyer.label - const shortAddr = `${buyer.address.slice(0, 6)}…${buyer.address.slice(-4)}` - if (buyer.type === 'deep-link') return `Partner ${shortAddr}` - if (buyer.type === 'imported') return `Import ${shortAddr}` - return 'Wallet buyer' +function shortAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` } function BuyerSelector({ @@ -33,7 +34,7 @@ function BuyerSelector({ activeBuyerAddress, onSelect, }: { - buyers: BuyerRecord[] + buyers: string[] activeBuyerAddress: string | null onSelect: (address: string) => void }) { @@ -46,10 +47,10 @@ function BuyerSelector({ {buyers.map((buyer) => { - const isActive = buyer.address.toLowerCase() === activeBuyerAddress?.toLowerCase() + const isActive = buyer.toLowerCase() === activeBuyerAddress?.toLowerCase() return ( { - if (!isActive) onSelect(buyer.address) + if (!isActive) onSelect(buyer) }} > @@ -73,18 +74,14 @@ function BuyerSelector({ fontWeight={isActive ? '700' : '500'} color={isActive ? '$primary' : '$color'} numberOfLines={1} + style={monospaceSingleLineStyle} > - {buyerDisplayLabel(buyer)} + {shortAddress(buyer)} - {buyer.address.slice(0, 10)}…{buyer.address.slice(-6)} + {buyer.slice(0, 10)}…{buyer.slice(-6)} - {buyer.type === 'deep-link' && ( - - partner - - )} {isActive && } ) @@ -126,7 +123,6 @@ function BuyerImportPanel({ value={inputValue} onChangeText={setInputValue} placeholder="0x…" - secureTextEntry autoFocus /> @@ -155,19 +151,22 @@ function BuyerImportPanel({ } export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { - const { address, buyerPubKey, buyerPrvKey, operatorConsented, buyers, activeBuyerAddress } = state + const { + address, + buyerPubKey, + buyerPrvKey, + operatorSignature, + operatorConsented, + buyers, + activeBuyerAddress, + } = state const { copied: copiedPrivate, copy: copyPrivate } = useCopyFeedback() const [isPrivateKeyVisible, setIsPrivateKeyVisible] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [isSigning, setIsSigning] = useState(false) const [showImport, setShowImport] = useState(false) - const activeBuyer = buyers.find( - (buyer) => - buyer.address.toLowerCase() === (activeBuyerAddress ?? buyerPubKey ?? '').toLowerCase(), - ) - const buyerCanSign = Boolean(buyerPrvKey || activeBuyer?.operatorSignature) - const hasDerivedBuyer = buyers.some((buyer) => buyer.type === 'derived') + const buyerCanSign = Boolean(buyerPrvKey || operatorSignature) return ( @@ -183,22 +182,20 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { /> - {!hasDerivedBuyer && ( - - )} + @@ -204,12 +205,11 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { size="sm" {...compactButtonProps} onPress={() => { - setIsSigning(true) - void Promise.resolve(actions.signOperatorConsent()).finally(() => setIsSigning(false)) + void Promise.resolve(actions.signOperatorConsent()) }} - disabled={operatorConsented || isSigning || !buyerCanSign} + disabled={operatorConsented || operatorConsentPending || !buyerCanSign} > - {isSigning ? ( + {operatorConsentPending ? ( ) : ( {operatorConsented ? 'Consented' : 'Sign Consent'} diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 860c1865..10f07648 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -43,6 +43,8 @@ export interface AiCreditsWidgetAdapterState { /** Active buyer deep-link operator signature, if present. */ operatorSignature: string | null operatorConsented: boolean + /** True while submitting / waiting for on-chain operator consent. */ + operatorConsentPending: boolean operatorAddress: string | null minDepositUsd: string | null minStreamUsd: string | null From 9a774933acfc31a99453b2443e1e83b8fe74ce16 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 5 Aug 2026 15:23:43 -0400 Subject: [PATCH 13/17] fix(ai-credits-widget): update payment status checks to include 'payment_failed' - Modified the condition in getPayDisabledMessage to allow for 'payment_failed' status, enabling users to retry payments. - Updated AmountPicker logic to reflect the new payment status handling, improving user experience during payment processes. --- packages/ai-credits-widget/src/components/buy/AmountPicker.tsx | 3 ++- packages/ai-credits-widget/src/vaultMinimums.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx index 72b061c9..f2a7b4e6 100644 --- a/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx +++ b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx @@ -181,8 +181,9 @@ export function AmountPicker({ [depositAmount, streamAmount, monthlyStreamG, minDepositUsd, minStreamUsd, quote, gdUsdPerToken, gBalance], ) const minsLoaded = minStreamUsd !== null + const canRetryAfterFailure = status === 'quote_ready' || status === 'payment_failed' const canPay = - status === 'quote_ready' && + canRetryAfterFailure && minsLoaded && paymentValidation.hasPaymentAction && paymentValidation.vaultMinimumsMet && diff --git a/packages/ai-credits-widget/src/vaultMinimums.ts b/packages/ai-credits-widget/src/vaultMinimums.ts index 3f5b964f..b2e7d3ed 100644 --- a/packages/ai-credits-widget/src/vaultMinimums.ts +++ b/packages/ai-credits-widget/src/vaultMinimums.ts @@ -118,7 +118,7 @@ export function getPayDisabledMessage(params: { if (params.validation.streamBelowMin && params.minStreamUsd) { return `Monthly stream must be at least ${formatMinUsdDisplay(params.minStreamUsd)}.` } - if (params.status !== 'quote_ready') { + if (params.status !== 'quote_ready' && params.status !== 'payment_failed') { return 'Enter a deposit or change the monthly stream amount to continue.' } return 'Adjust the amounts to continue.' From 8103f42e5be94ffaea453873a7a0390ebd6202ff Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 5 Aug 2026 16:41:49 -0400 Subject: [PATCH 14/17] feat(ai-credits-widget): enhance buyer address management and private key handling - Removed activeBuyerAddress from state management, replacing it with buyerPubKey for improved clarity. - Introduced rememberPrivateKeysOnDevice flag to manage private key storage preferences. - Updated various components and hooks to utilize the new state structure, enhancing user experience during buyer selection and key management. - Refactored Storybook stories to reflect changes in buyer address handling and private key options. --- .../helpers/aiCreditsWidgetStories.tsx | 8 +- .../ai-credits-widget/src/AiCreditsWidget.tsx | 2 +- packages/ai-credits-widget/src/adapter.ts | 451 +++++++++++------- .../ai-credits-widget/src/backendClient.ts | 1 + .../ai-credits-widget/src/backendTypes.ts | 1 + .../components/manage/BuyerOperatorCard.tsx | 97 +++- .../src/mocked/backendClient.ts | 4 + .../ai-credits-widget/src/payerSession.ts | 182 ++++++- .../src/useAiCreditsHistory.ts | 89 ++-- .../src/widgetRuntimeContract.ts | 15 +- 10 files changed, 598 insertions(+), 252 deletions(-) diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 7bbc93eb..79c7bd75 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -48,8 +48,8 @@ function createMockState( error: null, activeTab: 'buy', buyers: [], - activeBuyerAddress: null, derivedBuyerAddress: null, + rememberPrivateKeysOnDevice: false, } return { ...base, ...overrides } } @@ -64,9 +64,10 @@ function createAdapterFactory( connect: async () => {}, switchChain: async () => {}, generateBuyerKey: async () => {}, - selectBuyer: () => {}, + selectBuyer: async () => {}, discoverBuyers: () => {}, importBuyerFromPrivateKey: async () => {}, + setRememberPrivateKeysOnDevice: () => {}, applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, @@ -458,7 +459,6 @@ export function MultiBuyerManageStory() { gBalance: '42.50', activeTab: 'manage', buyers: [BUYER_WALLET.address, BUYER_IMPORTED.address, BUYER_PARTNER.address], - activeBuyerAddress: BUYER_WALLET.address, derivedBuyerAddress: BUYER_WALLET.address, })} /> @@ -478,7 +478,6 @@ export function DeepLinkBuyerStory() { gBalance: '42.50', activeTab: 'manage', buyers: [BUYER_PARTNER.address], - activeBuyerAddress: BUYER_PARTNER.address, })} /> ) @@ -497,7 +496,6 @@ export function MultiBuyerHistoryStory() { gBalance: '42.50', activeTab: 'history', buyers: [BUYER_WALLET.address, BUYER_IMPORTED.address], - activeBuyerAddress: BUYER_WALLET.address, derivedBuyerAddress: BUYER_WALLET.address, })} /> diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index 52f6a939..4d2bb055 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -332,7 +332,7 @@ function AiCreditsInner({ const history = useAiCreditsHistory({ address: state.address, backendUrl, - defaultBuyerFilter: state.activeBuyerAddress ?? 'all', + defaultBuyerFilter: state.buyerPubKey ?? 'all', environment, backendClient: adapterOptions?.backendClient, onBuyersDiscovered: actions.discoverBuyers, diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index b424aa75..33547907 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -42,12 +42,14 @@ import { type DeepLinkParams, } from './deepLinkParams' import { - addressesMatch, + buildBuyerStateFields, patchPayerSessionFields, - patchPayerSession, readPayerSession, upsertBuyerKey, setActiveBuyerAddress, + setBuyerOperatorConsented, + setRememberPrivateKeysOnDevice, + getRememberPrivateKeysOnDevice, mergeBuyerAddressList, rememberBuyerAddresses, listKnownBuyerAddresses, @@ -113,8 +115,8 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { error: null, activeTab: 'buy', buyers: [], - activeBuyerAddress: null, derivedBuyerAddress: null, + rememberPrivateKeysOnDevice: false, } const WALLET_LOADING_STATE: Partial = { @@ -132,7 +134,20 @@ const WALLET_LOADING_STATE: Partial = { const BUYER_HISTORY_LOOKUP_LIMIT = 100 -async function resolveLocalBuyerAddresses( +function resolveLocalBuyers( + payer: string, + preferredBuyer?: string | null, + ...extras: Array +): { buyers: string[]; selected: string | null } { + const buyers = rememberBuyerAddresses(payer, [ + preferredBuyer, + ...listKnownBuyerAddresses(payer), + ...extras, + ]) + return { buyers, selected: selectPreferredBuyer(buyers, preferredBuyer) } +} + +async function discoverBuyersFromHistory( payer: string, backend: AiCreditsBackendClient, ...extras: Array @@ -312,49 +327,13 @@ function viewToStatePatch( } } -function buyerSelectionFields( +function activateBuyerSelection( payer: string, buyers: string[], selectedAddress: string | null, -): Pick< - AiCreditsWidgetAdapterState, - | 'buyers' - | 'activeBuyerAddress' - | 'derivedBuyerAddress' - | 'buyerPubKey' - | 'buyerPrvKey' - | 'operatorSignature' - | 'operatorConsented' -> { - const selected = selectedAddress - const existing = readPayerSession(payer) - const alreadyActive = - !!selected && - !!existing?.activeBuyerAddress && - existing.activeBuyerAddress.toLowerCase() === selected.toLowerCase() - - if (selected && !alreadyActive) { - setActiveBuyerAddress(payer, selected) - } else if (!selected && existing?.activeBuyerAddress) { - setActiveBuyerAddress(payer, null) - } - - const session = patchPayerSessionFields(payer) - const entry = selected ? getBuyerKeyEntry(payer, selected) : null - return { - buyers, - activeBuyerAddress: selected, - derivedBuyerAddress: session.derivedBuyerAddress, - buyerPubKey: selected, - buyerPrvKey: entry?.privateKey ?? null, - operatorSignature: entry?.operatorSignature ?? null, - operatorConsented: selected ? session.operatorConsented : false, - } -} - -function syncOperatorConsentSession(address: string, operatorConsented: boolean | undefined): void { - if (operatorConsented === undefined) return - patchPayerSession(address, { operatorConsented }) +) { + setActiveBuyerAddress(payer, selectedAddress) + return buildBuyerStateFields(payer, buyers, selectedAddress) } export interface UseAiCreditsAdapterOptions { @@ -460,8 +439,8 @@ export function useAiCreditsAdapter({ buyerPrvKey: sessionPatch.buyerPrvKey, operatorSignature: sessionPatch.operatorSignature, operatorConsented: sessionPatch.operatorConsented, - activeBuyerAddress: sessionPatch.activeBuyerAddress, derivedBuyerAddress: sessionPatch.derivedBuyerAddress, + rememberPrivateKeysOnDevice: sessionPatch.rememberPrivateKeysOnDevice, buyers: prev.buyers, ...WALLET_LOADING_STATE, error: null, @@ -487,17 +466,21 @@ export function useAiCreditsAdapter({ }), ]) - const accountPromise = buildAccountView(address!, backendClient, chainClient, { - buyerAddress: - pendingDeepLinkRef.current?.buyerAddress ?? - patchPayerSessionFields(address!).activeBuyerAddress ?? - null, - }) - .then(async (view) => ({ - view, - enriched: await enrichAccountView(view, chainClient), - })) - .catch(() => null) + const pendingDeepLink = pendingDeepLinkRef.current + const sessionBuyer = patchPayerSessionFields(address!).buyerPubKey + const preferredBuyer = pendingDeepLink?.buyerAddress ?? sessionBuyer ?? null + + const accountPromise = + pendingDeepLink || deepLinkApplyInFlightRef.current + ? Promise.resolve(null) + : buildAccountView(address!, backendClient, chainClient, { + buyerAddress: preferredBuyer, + }) + .then(async (view) => ({ + view, + enriched: await enrichAccountView(view, chainClient), + })) + .catch(() => null) const minimumsPromise = skipVaultPaymentValidation @@ -509,15 +492,19 @@ export function useAiCreditsAdapter({ const gdUsdPerTokenPromise = chainClient.fetchGdUsdPerToken().catch(() => null) const discountConfigPromise = backendClient.getDiscountConfig().catch(() => null) - const sessionBuyer = patchPayerSessionFields(address!).activeBuyerAddress - const preferredBuyer = - pendingDeepLinkRef.current?.buyerAddress ?? sessionBuyer ?? null - const buyersPromise = resolveLocalBuyerAddresses( - address!, - backendClient, - preferredBuyer, - ...listKnownBuyerAddresses(address!), - ) + const buyersPromise = pendingDeepLink + ? Promise.resolve( + rememberBuyerAddresses(address!, [ + preferredBuyer, + ...listKnownBuyerAddresses(address!), + ]), + ) + : discoverBuyersFromHistory( + address!, + backendClient, + preferredBuyer, + ...listKnownBuyerAddresses(address!), + ) try { const [[rawBalance, decimals], account, minimums, gdUsdPerToken, discountConfig, buyers] = @@ -544,40 +531,47 @@ export function useAiCreditsAdapter({ discountConfig?.streamBonusPercent ?? DEFAULT_DISCOUNT_CONFIG.streamBonusPercent, } - setState((prev) => { - const accountPatch = account - ? viewToStatePatch(account.view, account.enriched, prev, { - balanceMode: 'always', - }) - : {} - const pendingDeepLink = pendingDeepLinkRef.current - const selectedBuyer = selectPreferredBuyer( - buyers, - pendingDeepLink?.buyerAddress ?? sessionBuyer, + if (pendingDeepLink || deepLinkApplyInFlightRef.current) { + setState((prev) => + withDerivedStatus( + prev, + { + ...patch, + buyers: mergeBuyerAddressList(prev.buyers, ...buyers), + rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address!), + ...(account ? {} : { activeTab: 'buy' as const }), + }, + true, + ), ) - const buyerFields = buyerSelectionFields(address!, buyers, selectedBuyer) - if (pendingDeepLink?.operatorSignature && selectedBuyer) { - upsertBuyerKey(address!, selectedBuyer, { - operatorSignature: pendingDeepLink.operatorSignature, + return + } + + const selectedBuyer = selectPreferredBuyer(buyers, preferredBuyer) + const accountPatch = account + ? viewToStatePatch(account.view, account.enriched, INITIAL_STATE, { + balanceMode: 'always', }) - buyerFields.operatorSignature = pendingDeepLink.operatorSignature - buyerFields.buyerPrvKey = - getBuyerKeyEntry(address!, selectedBuyer)?.privateKey ?? null - } - if (address && accountPatch.operatorConsented !== undefined && selectedBuyer) { - syncOperatorConsentSession(address, accountPatch.operatorConsented) - } - return withDerivedStatus( + : {} + if (selectedBuyer && accountPatch.operatorConsented !== undefined) { + setBuyerOperatorConsented(address!, selectedBuyer, accountPatch.operatorConsented) + } + const buyerFields = activateBuyerSelection(address!, buyers, selectedBuyer) + setState((prev) => + withDerivedStatus( prev, { ...patch, ...accountPatch, ...buyerFields, + rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address!), + operatorConsented: + accountPatch.operatorConsented ?? buyerFields.operatorConsented, ...(account ? {} : { activeTab: 'buy' as const }), }, true, - ) - }) + ), + ) } catch { if (cancelled) return setState((prev) => { @@ -588,11 +582,12 @@ export function useAiCreditsAdapter({ chainId, gBalance: '0', buyers: [], - activeBuyerAddress: null, derivedBuyerAddress: null, buyerPubKey: null, buyerPrvKey: null, operatorSignature: null, + operatorConsented: false, + rememberPrivateKeysOnDevice: false, status: chainId !== null && chainId !== CELO_CHAIN_ID ? 'unsupported_chain' @@ -660,14 +655,18 @@ export function useAiCreditsAdapter({ const existingKey = derivedAddress ? getBuyerKeyEntry(payerAddress, derivedAddress) : null if (derivedAddress && existingKey?.privateKey) { - setState((prev) => { - const buyers = mergeBuyerAddressList(prev.buyers, derivedAddress) - return mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(payerAddress, buyers, derivedAddress), + const buyers = mergeBuyerAddressList( + listKnownBuyerAddresses(payerAddress), + derivedAddress, + ) + const buyerFields = activateBuyerSelection(payerAddress, buyers, derivedAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }) - }) + }), + ) return } @@ -692,14 +691,19 @@ export function useAiCreditsAdapter({ { setActive: true, setDerived: true }, ) - setState((prev) => { - const buyers = mergeBuyerAddressList(prev.buyers, buyerAccount.address) - return mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(payerAddress, buyers, buyerAccount.address), + const buyers = mergeBuyerAddressList( + listKnownBuyerAddresses(payerAddress), + buyerAccount.address, + ) + const buyerFields = buildBuyerStateFields(payerAddress, buyers, buyerAccount.address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(payerAddress), error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), - }) - }) + }), + ) } catch (err: unknown) { setState((prev) => withDerivedStatus( @@ -714,14 +718,18 @@ export function useAiCreditsAdapter({ }, [address]) const handleSelectBuyer = useCallback( - (buyerAddress: string) => { + async (buyerAddress: string) => { if (!address) return - setState((prev) => { - if (!prev.buyers.some((item) => item.toLowerCase() === buyerAddress.toLowerCase())) { - return prev - } - return mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(address, prev.buyers, buyerAddress), + const known = listKnownBuyerAddresses(address) + if (!known.some((item) => item.toLowerCase() === buyerAddress.toLowerCase())) { + return + } + + const buyers = mergeBuyerAddressList(known, buyerAddress) + const buyerFields = activateBuyerSelection(address, buyers, buyerAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -729,10 +737,39 @@ export function useAiCreditsAdapter({ monthlyStreamG: null, operatorConsentPending: false, error: null, + }), + ) + + try { + const view = await buildAccountView(address, backendClient, chainClient, { + buyerAddress, }) - }) + const enriched = await enrichAccountView(view, chainClient) + const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, { + balanceMode: 'always', + }) + if (accountPatch.operatorConsented !== undefined) { + setBuyerOperatorConsented(address, buyerAddress, accountPatch.operatorConsented) + } + const nextBuyerFields = buildBuyerStateFields(address, buyers, buyerAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...accountPatch, + ...nextBuyerFields, + operatorConsented: + accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, + error: null, + }), + ) + } catch (err: unknown) { + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + error: err instanceof Error ? err.message : 'Could not load buyer account', + }), + ) + } }, - [address], + [address, backendClient, chainClient], ) const handleDiscoverBuyers = useCallback( @@ -754,7 +791,7 @@ export function useAiCreditsAdapter({ ) const handleImportBuyerFromPrivateKey = useCallback( - async (rawPrivateKey: string) => { + async (rawPrivateKey: string, options?: { rememberOnDevice?: boolean }) => { if (!address) { setState((prev) => withDerivedStatus(prev, { error: 'Connect your wallet before importing a buyer key' }, true), @@ -778,12 +815,20 @@ export function useAiCreditsAdapter({ try { const privateKey = normalized as `0x${string}` const buyerAccount = privateKeyToAccount(privateKey) + if (options?.rememberOnDevice) { + setRememberPrivateKeysOnDevice(address, true) + } upsertBuyerKey(address, buyerAccount.address, { privateKey }, { setActive: true }) - setState((prev) => { - const buyers = mergeBuyerAddressList(prev.buyers, buyerAccount.address) - return mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(address, buyers, buyerAccount.address), + const buyers = mergeBuyerAddressList( + listKnownBuyerAddresses(address), + buyerAccount.address, + ) + const buyerFields = buildBuyerStateFields(address, buyers, buyerAccount.address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address), operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -791,24 +836,62 @@ export function useAiCreditsAdapter({ monthlyStreamG: null, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), + }), + ) + + try { + const view = await buildAccountView(address, backendClient, chainClient, { + buyerAddress: buyerAccount.address, }) - }) + const enriched = await enrichAccountView(view, chainClient) + const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, { + balanceMode: 'always', + }) + if (accountPatch.operatorConsented !== undefined) { + setBuyerOperatorConsented( + address, + buyerAccount.address, + accountPatch.operatorConsented, + ) + } + const nextBuyerFields = buildBuyerStateFields( + address, + buyers, + buyerAccount.address, + ) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...accountPatch, + ...nextBuyerFields, + operatorConsented: + accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, + error: null, + }), + ) + } catch { + return + } } catch { setState((prev) => withDerivedStatus(prev, { error: 'Could not derive an account from the provided private key' }, true), ) } }, - [address], + [address, backendClient, chainClient], ) - const reloadBuyerAddresses = useCallback( - async (payer: string, preferredBuyer?: string | null) => { - const buyers = await resolveLocalBuyerAddresses(payer, backendClient, preferredBuyer) - const selected = selectPreferredBuyer(buyers, preferredBuyer) - return { buyers, selected } + const handleSetRememberPrivateKeysOnDevice = useCallback( + (remember: boolean) => { + if (!address) return + setRememberPrivateKeysOnDevice(address, remember) + setState((prev) => ({ ...prev, rememberPrivateKeysOnDevice: remember })) }, - [backendClient], + [address], + ) + + const resolveBuyerList = useCallback( + (payer: string, preferredBuyer?: string | null) => resolveLocalBuyers(payer, preferredBuyer), + [], ) /** @@ -866,10 +949,11 @@ export function useAiCreditsAdapter({ { setActive: true }, ) - setState((prev) => { - const buyers = mergeBuyerAddressList(prev.buyers, trimmedAddress) - return mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(address, buyers, trimmedAddress), + const buyers = mergeBuyerAddressList(listKnownBuyerAddresses(address), trimmedAddress) + const buyerFields = buildBuyerStateFields(address, buyers, trimmedAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -878,8 +962,8 @@ export function useAiCreditsAdapter({ activeTab: 'buy', operatorConsentPending: true, error: null, - }) - }) + }), + ) const ref: AccountRef = { payer: address, buyer: trimmedAddress } @@ -898,13 +982,34 @@ export function useAiCreditsAdapter({ await waitForOperatorConsent(chainClient, ref) } - const { buyers, selected } = await reloadBuyerAddresses(address, trimmedAddress) - patchPayerSession(address, { operatorConsented: true }) + setBuyerOperatorConsented(address, trimmedAddress, true) + const buyerList = resolveBuyerList(address, trimmedAddress) + let accountPatch: Partial = {} + try { + const view = await buildAccountView(address, backendClient, chainClient, { + buyerAddress: trimmedAddress, + }) + const enriched = await enrichAccountView(view, chainClient) + accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, { + balanceMode: 'always', + }) + if (accountPatch.operatorConsented !== undefined) { + setBuyerOperatorConsented(address, trimmedAddress, accountPatch.operatorConsented) + } + } catch { + accountPatch = {} + } + const nextBuyerFields = buildBuyerStateFields( + address, + buyerList.buyers, + buyerList.selected, + ) setState((prev) => withDerivedStatus( prev, { - ...buyerSelectionFields(address, buyers, selected), + ...accountPatch, + ...nextBuyerFields, operatorConsented: true, operatorConsentPending: false, activeTab: 'buy', @@ -933,7 +1038,7 @@ export function useAiCreditsAdapter({ ) } }, - [address, backendClient, chainClient, reloadBuyerAddresses], + [address, backendClient, chainClient, resolveBuyerList], ) const handleSignOperatorConsent = useCallback(async () => { @@ -983,14 +1088,16 @@ export function useAiCreditsAdapter({ } if (operatorStatus.operatorAccepted) { - const { buyers, selected } = await reloadBuyerAddresses( + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, true) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( currentState.address, - currentState.buyerPubKey, + buyerList.buyers, + buyerList.selected, ) - patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(currentState.address!, buyers, selected), + ...buyerFields, operatorConsented: true, operatorConsentPending: false, error: null, @@ -1024,14 +1131,16 @@ export function useAiCreditsAdapter({ }) await waitForOperatorConsent(chainClient, ref) - const { buyers, selected } = await reloadBuyerAddresses( + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, true) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( currentState.address, - currentState.buyerPubKey, + buyerList.buyers, + buyerList.selected, ) - patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => mergeStatePreservingNonBuyTab(prev, { - ...buyerSelectionFields(currentState.address!, buyers, selected), + ...buyerFields, operatorConsented: true, operatorConsentPending: false, error: null, @@ -1045,7 +1154,7 @@ export function useAiCreditsAdapter({ error: err instanceof Error ? err.message : 'Operator consent signature rejected', })) } - }, [state, backendClient, chainClient, reloadBuyerAddresses]) + }, [state, backendClient, chainClient, resolveBuyerList]) const handleSyncOperatorConsentFromChain = useCallback(async () => { const currentState = state @@ -1058,7 +1167,7 @@ export function useAiCreditsAdapter({ const operatorStatus = await chainClient.getBuyerOperatorStatus(ref) if (!operatorStatus.operatorAccepted) return - patchPayerSession(currentState.address, { operatorConsented: true }) + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, true) const onNonBuyTab = isNonBuyTab(currentState.activeTab) setState((prev) => mergeStatePreservingNonBuyTab(prev, { @@ -1216,18 +1325,16 @@ export function useAiCreditsAdapter({ const creditUsdMicro = (BigInt(totalCreditUsd) - BigInt(balanceBefore || '0')).toString() - const buyerList = await reloadBuyerAddresses( + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( currentState.address, - currentState.buyerPubKey, + buyerList.buyers, + buyerList.selected, ) setState((prev) => withDerivedStatus(prev, { - ...buyerSelectionFields( - currentState.address!, - buyerList.buyers, - buyerList.selected, - ), + ...buyerFields, totalCreditUsd, error: null, activeTab: 'manage', @@ -1263,7 +1370,7 @@ export function useAiCreditsAdapter({ celoVault, onPaySuccess, onPayError, - reloadBuyerAddresses, + resolveBuyerList, prepareSettlement, skipVaultPaymentValidation, ], @@ -1276,27 +1383,35 @@ export function useAiCreditsAdapter({ try { const preferredBuyer = currentState.buyerPubKey - const [view, discountConfig, buyerList] = await Promise.all([ + const buyerList = resolveBuyerList(currentState.address, preferredBuyer) + const [view, discountConfig] = await Promise.all([ buildAccountView(currentState.address, backendClient, chainClient, { buyerAddress: preferredBuyer, }), backendClient.getDiscountConfig().catch(() => null), - reloadBuyerAddresses(currentState.address, preferredBuyer), ]) const enriched = await enrichAccountView(view, chainClient) + const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, { + balanceMode: 'always', + }) + if ( + preferredBuyer && + accountPatch.operatorConsented !== undefined && + currentState.address + ) { + setBuyerOperatorConsented( + currentState.address, + preferredBuyer, + accountPatch.operatorConsented, + ) + } + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) setState((prev) => { - const accountPatch = viewToStatePatch(view, enriched, prev, { - balanceMode: 'always', - }) - const buyerFields = buyerSelectionFields( - currentState.address!, - buyerList.buyers, - buyerList.selected, - ) - if (accountPatch.operatorConsented !== undefined && currentState.address) { - syncOperatorConsentSession(currentState.address, accountPatch.operatorConsented) - } const statusSeed = options?.afterGoodIdVerify && prev.status === 'payment_failed' ? 'quote_ready' @@ -1308,6 +1423,8 @@ export function useAiCreditsAdapter({ { ...accountPatch, ...buyerFields, + operatorConsented: + accountPatch.operatorConsented ?? buyerFields.operatorConsented, activeTab: prev.activeTab, error: null, depositBonusPercent: @@ -1326,7 +1443,7 @@ export function useAiCreditsAdapter({ ) } }, - [state, backendClient, chainClient, reloadBuyerAddresses], + [state, backendClient, chainClient, resolveBuyerList], ) const handleVerifyGoodId = useCallback(async (): Promise => { @@ -1588,6 +1705,7 @@ export function useAiCreditsAdapter({ selectBuyer: handleSelectBuyer, discoverBuyers: handleDiscoverBuyers, importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, + setRememberPrivateKeysOnDevice: handleSetRememberPrivateKeysOnDevice, applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, @@ -1608,6 +1726,7 @@ export function useAiCreditsAdapter({ handleSelectBuyer, handleDiscoverBuyers, handleImportBuyerFromPrivateKey, + handleSetRememberPrivateKeysOnDevice, handleApplyDeepLinkBuyer, handleSignOperatorConsent, handleSyncOperatorConsentFromChain, diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index 6abbd1af..26487ba8 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -295,6 +295,7 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient if (options.fundingStatus) params.set('fundingStatus', options.fundingStatus) if (options.from) params.set('from', options.from) if (options.to) params.set('to', options.to) + if (options.buyerAddress) params.set('buyerAddress', options.buyerAddress) const response = await fetch(`${this.accountBase(payer)}/credit-history?${params.toString()}`) if (!response.ok) throw new Error(`Credit history request failed: ${response.status}`) diff --git a/packages/ai-credits-widget/src/backendTypes.ts b/packages/ai-credits-widget/src/backendTypes.ts index 7aee006b..04c14e16 100644 --- a/packages/ai-credits-widget/src/backendTypes.ts +++ b/packages/ai-credits-widget/src/backendTypes.ts @@ -59,6 +59,7 @@ export type CreditHistoryQuery = { fundingStatus?: GdCreditEntry['fundingStatus'] from?: string to?: string + buyerAddress?: string } export type AccountView = { diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index a3c4c9db..ac54d1f5 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -15,13 +15,14 @@ interface BuyerOperatorCardProps { | 'operatorConsented' | 'operatorConsentPending' | 'buyers' - | 'activeBuyerAddress' + | 'rememberPrivateKeysOnDevice' > actions: Pick< AiCreditsWidgetAdapterActions, | 'generateBuyerKey' | 'selectBuyer' | 'importBuyerFromPrivateKey' + | 'setRememberPrivateKeysOnDevice' | 'signOperatorConsent' > } @@ -66,23 +67,19 @@ function BuyerSelector({ cursor={isActive ? 'default' : 'pointer'} hoverStyle={isActive ? {} : { backgroundColor: '$backgroundPress' }} onPress={() => { - if (!isActive) onSelect(buyer) + if (!isActive) void onSelect(buyer) }} > - - - {shortAddress(buyer)} - - - {buyer.slice(0, 10)}…{buyer.slice(-6)} - - + + {shortAddress(buyer)} + {isActive && } ) @@ -96,17 +93,21 @@ function BuyerImportPanel({ onImportPrivateKey, onClose, }: { - onImportPrivateKey: (key: string) => Promise + onImportPrivateKey: ( + key: string, + options?: { rememberOnDevice?: boolean }, + ) => Promise onClose: () => void }) { const [inputValue, setInputValue] = useState('') + const [rememberOnDevice, setRememberOnDevice] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) async function handleSubmit() { if (!inputValue.trim()) return setIsSubmitting(true) try { - await onImportPrivateKey(inputValue.trim()) + await onImportPrivateKey(inputValue.trim(), { rememberOnDevice }) setInputValue('') onClose() } finally { @@ -126,6 +127,31 @@ function BuyerImportPanel({ placeholder="0x…" autoFocus /> + setRememberOnDevice((prev) => !prev)} + > + + {rememberOnDevice ? : null} + + + Remember on this device (localStorage). Keys otherwise stay in this tab only. + + + + actions.setRememberPrivateKeysOnDevice(!rememberPrivateKeysOnDevice) + } + > + + {rememberPrivateKeysOnDevice ? ( + + ) : null} + + + {rememberPrivateKeysOnDevice + ? 'Remembered on this device (localStorage).' + : 'Stored in this browser tab only (clears when the tab closes).'} + + )} diff --git a/packages/ai-credits-widget/src/mocked/backendClient.ts b/packages/ai-credits-widget/src/mocked/backendClient.ts index 165a64c5..e312608a 100644 --- a/packages/ai-credits-widget/src/mocked/backendClient.ts +++ b/packages/ai-credits-widget/src/mocked/backendClient.ts @@ -40,6 +40,10 @@ function paginateGdCredits( }) if (options.source) result = result.filter((entry) => entry.source === options.source) if (options.fundingStatus) result = result.filter((entry) => entry.fundingStatus === options.fundingStatus) + if (options.buyerAddress) { + const buyer = normalizeAddress(options.buyerAddress) + result = result.filter((entry) => entry.buyerAddress && normalizeAddress(entry.buyerAddress) === buyer) + } if (options.from) { const fromMs = Date.parse(options.from) result = result.filter((entry) => Date.parse(entry.createdAt) >= fromMs) diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index 677d6781..534bda47 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -1,6 +1,7 @@ export type BuyerKeyEntry = { privateKey?: string operatorSignature?: string + operatorConsented?: boolean } export type PayerWalletSession = { @@ -8,11 +9,21 @@ export type PayerWalletSession = { knownBuyers: string[] activeBuyerAddress: string | null derivedBuyerAddress: string | null + rememberPrivateKeysOnDevice: boolean +} + +export type BuyerStateFields = { + buyers: string[] + buyerPubKey: string | null + buyerPrvKey: string | null + operatorSignature: string | null operatorConsented: boolean + derivedBuyerAddress: string | null } const MEMORY_SESSIONS = new Map() const STORAGE_KEY_PREFIX = 'goodwidget.ai-credits.payerSession.' +const PRIVATE_KEYS_SESSION_PREFIX = 'goodwidget.ai-credits.privateKeys.' function payerSessionKey(address: string): string { return address.toLowerCase() @@ -26,6 +37,10 @@ function canUseLocalStorage(): boolean { return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' } +function canUseSessionStorage(): boolean { + return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined' +} + function formatBuyerAddress(address: string): string { const key = normalizeBuyerAddress(address) return key.startsWith('0x') ? `0x${key.slice(2)}` : key @@ -37,7 +52,7 @@ function emptySession(): PayerWalletSession { knownBuyers: [], activeBuyerAddress: null, derivedBuyerAddress: null, - operatorConsented: false, + rememberPrivateKeysOnDevice: false, } } @@ -46,10 +61,78 @@ function isBuyerKeyEntry(value: unknown): value is BuyerKeyEntry { const entry = value as Record return ( (entry.privateKey === undefined || typeof entry.privateKey === 'string') && - (entry.operatorSignature === undefined || typeof entry.operatorSignature === 'string') + (entry.operatorSignature === undefined || typeof entry.operatorSignature === 'string') && + (entry.operatorConsented === undefined || typeof entry.operatorConsented === 'boolean') ) } +function extractPrivateKeys(session: PayerWalletSession): Record { + const keys: Record = {} + for (const [address, entry] of Object.entries(session.buyerKeys)) { + if (entry.privateKey) keys[address] = entry.privateKey + } + return keys +} + +function stripPrivateKeys(session: PayerWalletSession): PayerWalletSession { + const buyerKeys: Record = {} + for (const [address, entry] of Object.entries(session.buyerKeys)) { + buyerKeys[address] = { + operatorSignature: entry.operatorSignature, + operatorConsented: entry.operatorConsented, + } + } + return { ...session, buyerKeys } +} + +function mergePrivateKeyMap( + session: PayerWalletSession, + privateKeys: Record, +): PayerWalletSession { + if (Object.keys(privateKeys).length === 0) return session + const buyerKeys = { ...session.buyerKeys } + for (const [address, privateKey] of Object.entries(privateKeys)) { + const key = normalizeBuyerAddress(address) + buyerKeys[key] = { + ...buyerKeys[key], + privateKey, + } + } + return { ...session, buyerKeys } +} + +function readPrivateKeysFromSessionStorage(payer: string): Record { + if (!canUseSessionStorage()) return {} + try { + const raw = window.sessionStorage.getItem(`${PRIVATE_KEYS_SESSION_PREFIX}${payerSessionKey(payer)}`) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + const result: Record = {} + for (const [address, value] of Object.entries(parsed)) { + if (typeof value === 'string' && value) { + result[normalizeBuyerAddress(address)] = value + } + } + return result + } catch { + return {} + } +} + +function writePrivateKeysToSessionStorage(payer: string, privateKeys: Record): void { + if (!canUseSessionStorage()) return + try { + const key = `${PRIVATE_KEYS_SESSION_PREFIX}${payerSessionKey(payer)}` + if (Object.keys(privateKeys).length === 0) { + window.sessionStorage.removeItem(key) + return + } + window.sessionStorage.setItem(key, JSON.stringify(privateKeys)) + } catch { + return + } +} + function migrateLegacySession(raw: Record): PayerWalletSession { const session = emptySession() const known = new Set() @@ -87,7 +170,10 @@ function migrateLegacySession(raw: Record): PayerWalletSession if (typeof buyer.operatorSignature === 'string' && buyer.operatorSignature) { entry.operatorSignature = buyer.operatorSignature } - if (entry.privateKey || entry.operatorSignature) { + if (typeof buyer.operatorConsented === 'boolean') { + entry.operatorConsented = buyer.operatorConsented + } + if (entry.privateKey || entry.operatorSignature || entry.operatorConsented !== undefined) { session.buyerKeys[address] = { ...session.buyerKeys[address], ...entry, @@ -113,9 +199,6 @@ function migrateLegacySession(raw: Record): PayerWalletSession session.derivedBuyerAddress = normalizeBuyerAddress(raw.derivedBuyerAddress) trackKnown(session.derivedBuyerAddress) } - if (typeof raw.operatorConsented === 'boolean') { - session.operatorConsented = raw.operatorConsented - } if (typeof raw.operatorSignature === 'string' && raw.operatorSignature && session.activeBuyerAddress) { const active = session.activeBuyerAddress session.buyerKeys[active] = { @@ -136,6 +219,18 @@ function migrateLegacySession(raw: Record): PayerWalletSession } } + if (typeof raw.rememberPrivateKeysOnDevice === 'boolean') { + session.rememberPrivateKeysOnDevice = raw.rememberPrivateKeysOnDevice + } + + if (typeof raw.operatorConsented === 'boolean' && raw.operatorConsented && session.activeBuyerAddress) { + const active = session.activeBuyerAddress + session.buyerKeys[active] = { + ...session.buyerKeys[active], + operatorConsented: true, + } + } + session.knownBuyers = [...known].map((key) => formatBuyerAddress(key)) return session } @@ -143,9 +238,6 @@ function migrateLegacySession(raw: Record): PayerWalletSession function parseStoredSession(raw: string): PayerWalletSession | null { try { const parsed = JSON.parse(raw) as Record - if (parsed.buyerKeys && typeof parsed.buyerKeys === 'object' && !Array.isArray(parsed.buyers)) { - return migrateLegacySession(parsed) - } return migrateLegacySession(parsed) } catch { return null @@ -154,11 +246,16 @@ function parseStoredSession(raw: string): PayerWalletSession | null { function persistSession(address: string, session: PayerWalletSession): void { MEMORY_SESSIONS.set(payerSessionKey(address), session) + + const privateKeys = extractPrivateKeys(session) + writePrivateKeysToSessionStorage(address, privateKeys) + if (!canUseLocalStorage()) return try { + const forDisk = session.rememberPrivateKeysOnDevice ? session : stripPrivateKeys(session) window.localStorage.setItem( `${STORAGE_KEY_PREFIX}${payerSessionKey(address)}`, - JSON.stringify(session), + JSON.stringify(forDisk), ) } catch { return @@ -171,22 +268,24 @@ export function readPayerSession(address: string | null): PayerWalletSession | n const cached = MEMORY_SESSIONS.get(key) if (cached) return cached + let session: PayerWalletSession | null = null if (canUseLocalStorage()) { try { const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${key}`) if (raw) { - const parsed = parseStoredSession(raw) - if (parsed) { - MEMORY_SESSIONS.set(key, parsed) - return parsed - } + session = parseStoredSession(raw) } } catch { - return null + session = null } } - return null + if (!session) return null + + const fromSessionStorage = readPrivateKeysFromSessionStorage(address) + session = mergePrivateKeyMap(session, fromSessionStorage) + MEMORY_SESSIONS.set(key, session) + return session } export function patchPayerSession(address: string, patch: Partial): void { @@ -205,6 +304,23 @@ export function getBuyerKeyEntry(payer: string, buyerAddress: string): BuyerKeyE return session.buyerKeys[normalizeBuyerAddress(buyerAddress)] ?? null } +export function setBuyerOperatorConsented( + payer: string, + buyerAddress: string, + operatorConsented: boolean, +): void { + upsertBuyerKey(payer, buyerAddress, { operatorConsented }, { setActive: false }) +} + +export function setRememberPrivateKeysOnDevice(payer: string, remember: boolean): void { + const existing = readPayerSession(payer) ?? emptySession() + persistSession(payer, { ...existing, rememberPrivateKeysOnDevice: remember }) +} + +export function getRememberPrivateKeysOnDevice(payer: string): boolean { + return readPayerSession(payer)?.rememberPrivateKeysOnDevice ?? false +} + export function upsertBuyerKey( payer: string, buyerAddress: string, @@ -223,11 +339,14 @@ export function upsertBuyerKey( [key]: { privateKey: entry.privateKey ?? previous.privateKey, operatorSignature: entry.operatorSignature ?? previous.operatorSignature, + operatorConsented: + entry.operatorConsented !== undefined + ? entry.operatorConsented + : previous.operatorConsented, }, }, activeBuyerAddress: options?.setActive === false ? existing.activeBuyerAddress : key, derivedBuyerAddress: options?.setDerived ? key : existing.derivedBuyerAddress, - operatorConsented: options?.setActive === false ? existing.operatorConsented : false, } persistSession(payer, next) return next @@ -242,7 +361,6 @@ export function setActiveBuyerAddress(payer: string, buyerAddress: string | null ? mergeBuyerAddressList(existing.knownBuyers, normalized) : existing.knownBuyers, activeBuyerAddress: normalized, - operatorConsented: false, } persistSession(payer, next) return next @@ -266,7 +384,9 @@ export function mergeBuyerAddressList( return result } -export function normalizeBuyerAddressList(buyers: Array | undefined | null): string[] { +export function normalizeBuyerAddressList( + buyers: Array | undefined | null, +): string[] { if (!buyers || buyers.length === 0) return [] const seen = new Set() const result: string[] = [] @@ -310,6 +430,23 @@ export function rememberBuyerAddresses( return listKnownBuyerAddresses(payer) } +export function buildBuyerStateFields( + payer: string, + buyers: string[], + selectedAddress: string | null, +): BuyerStateFields { + const session = readPayerSession(payer) + const entry = selectedAddress ? getBuyerKeyEntry(payer, selectedAddress) : null + return { + buyers, + buyerPubKey: selectedAddress, + buyerPrvKey: entry?.privateKey ?? null, + operatorSignature: entry?.operatorSignature ?? null, + operatorConsented: Boolean(entry?.operatorConsented), + derivedBuyerAddress: session?.derivedBuyerAddress ?? null, + } +} + export function patchPayerSessionFields(address: string | null): { buyerPubKey: string | null buyerPrvKey: string | null @@ -317,6 +454,7 @@ export function patchPayerSessionFields(address: string | null): { operatorConsented: boolean activeBuyerAddress: string | null derivedBuyerAddress: string | null + rememberPrivateKeysOnDevice: boolean } { const session = readPayerSession(address) if (!session) { @@ -327,6 +465,7 @@ export function patchPayerSessionFields(address: string | null): { operatorConsented: false, activeBuyerAddress: null, derivedBuyerAddress: null, + rememberPrivateKeysOnDevice: false, } } @@ -337,9 +476,10 @@ export function patchPayerSessionFields(address: string | null): { buyerPubKey: active, buyerPrvKey: entry?.privateKey ?? null, operatorSignature: entry?.operatorSignature ?? null, - operatorConsented: session.operatorConsented, + operatorConsented: Boolean(entry?.operatorConsented), activeBuyerAddress: active, derivedBuyerAddress: session.derivedBuyerAddress, + rememberPrivateKeysOnDevice: session.rememberPrivateKeysOnDevice, } } diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts index 3c81ce9d..de64bdb0 100644 --- a/packages/ai-credits-widget/src/useAiCreditsHistory.ts +++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts @@ -6,11 +6,11 @@ import type { AiCreditsWidgetEnvironment } from './widgetRuntimeContract' export const HISTORY_PAGE_SIZE = 10 export const HISTORY_LOOKBACK_DAYS = 90 +const BUYER_FILTER_FILL_MAX_PAGES = 8 export type CreditHistorySource = GdCreditEntry['source'] export type CreditHistoryStatusFilter = 'all' | GdCreditEntry['fundingStatus'] -/** Special sentinel meaning "show entries for every known buyer". */ export const BUYER_FILTER_ALL = 'all' as const export type BuyerAddressFilter = typeof BUYER_FILTER_ALL | string @@ -59,10 +59,14 @@ function toIsoEndOfDay(dateValue: string): string | undefined { return new Date(parsed).toISOString() } +function matchesBuyerFilter(entry: GdCreditEntry, filter: BuyerAddressFilter): boolean { + if (filter === BUYER_FILTER_ALL) return true + return entry.buyerAddress?.toLowerCase() === filter.toLowerCase() +} + export interface AiCreditsHistoryState { selectedSources: Record statusFilter: CreditHistoryStatusFilter - /** Address of the selected buyer to filter by, or `'all'` to show all buyers. */ buyerAddressFilter: BuyerAddressFilter fromDate: string toDate: string @@ -78,7 +82,6 @@ export interface AiCreditsHistoryState { export interface AiCreditsHistoryActions { setSourceChecked: (source: CreditHistorySource, checked: boolean) => void setStatusFilter: (status: CreditHistoryStatusFilter) => void - /** Sets the buyer address filter; pass `'all'` to show all buyers. */ setBuyerAddressFilter: (value: BuyerAddressFilter) => void setFromDate: (value: string) => void setToDate: (value: string) => void @@ -121,6 +124,10 @@ export function useAiCreditsHistory(options: { const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) + useEffect(() => { + setBuyerAddressFilter(defaultBuyerFilter) + }, [defaultBuyerFilter]) + const activeSources = useMemo( () => HISTORY_SOURCE_OPTIONS.map((option) => option.id).filter((id) => selectedSources[id]), [selectedSources], @@ -155,40 +162,54 @@ export function useAiCreditsHistory(options: { const client = backendClient ?? createBackendClient(backendUrl) const apiSource = activeSources.length === 1 ? activeSources[0] : undefined const fundingStatus = statusFilter === 'all' ? undefined : statusFilter + const apiBuyerAddress = + buyerAddressFilter === BUYER_FILTER_ALL ? undefined : buyerAddressFilter try { - const response = await client.getCreditHistory(address, { - limit: HISTORY_PAGE_SIZE, - offset: nextOffset, - source: apiSource, - fundingStatus, - from: toIsoStartOfDay(fromDate), - to: toIsoEndOfDay(toDate), - }) - - const sourceFiltered = - activeSources.length === 1 - ? response.items - : response.items.filter((entry) => selectedSources[entry.source]) - - const discoveredBuyers = sourceFiltered - .map((entry) => entry.buyerAddress) - .filter((value): value is string => Boolean(value)) - if (discoveredBuyers.length > 0) { - onBuyersDiscovered?.(discoveredBuyers) + const collected: GdCreditEntry[] = [] + let cursor = nextOffset + let apiHasMore = true + let pages = 0 + + while (pages < BUYER_FILTER_FILL_MAX_PAGES && collected.length < HISTORY_PAGE_SIZE && apiHasMore) { + const response = await client.getCreditHistory(address, { + limit: HISTORY_PAGE_SIZE, + offset: cursor, + source: apiSource, + fundingStatus, + from: toIsoStartOfDay(fromDate), + to: toIsoEndOfDay(toDate), + buyerAddress: apiBuyerAddress, + }) + + const sourceFiltered = + activeSources.length === 1 + ? response.items + : response.items.filter((entry) => selectedSources[entry.source]) + + const discoveredBuyers = sourceFiltered + .map((entry) => entry.buyerAddress) + .filter((value): value is string => Boolean(value)) + if (discoveredBuyers.length > 0) { + onBuyersDiscovered?.(discoveredBuyers) + } + + const buyerFiltered = sourceFiltered.filter((entry) => + matchesBuyerFilter(entry, buyerAddressFilter), + ) + collected.push(...buyerFiltered) + + apiHasMore = response.hasMore + cursor = response.offset + response.limit + pages += 1 + + if (!apiBuyerAddress) break + if (buyerFiltered.length === response.items.length) break } - const buyerFiltered = - buyerAddressFilter === BUYER_FILTER_ALL - ? sourceFiltered - : sourceFiltered.filter( - (entry) => - entry.buyerAddress?.toLowerCase() === buyerAddressFilter.toLowerCase(), - ) - - setEntries((prev) => (append ? [...prev, ...buyerFiltered] : buyerFiltered)) - setOffset(nextOffset) - setHasMore(response.hasMore) + setEntries((prev) => (append ? [...prev, ...collected] : collected)) + setOffset(cursor) + setHasMore(apiHasMore) } catch (err: unknown) { if (!append) setEntries([]) setHasMore(false) @@ -231,7 +252,7 @@ export function useAiCreditsHistory(options: { }, [loadHistory]) const loadMore = useCallback(async () => { - await loadHistory(offset + HISTORY_PAGE_SIZE, true) + await loadHistory(offset, true) }, [loadHistory, offset]) return { diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 10f07648..a0ad8c7b 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -56,10 +56,10 @@ export interface AiCreditsWidgetAdapterState { error: string | null activeTab: AiCreditsWidgetTab buyers: string[] - /** Address of the currently selected buyer (matches `buyerPubKey`). */ - activeBuyerAddress: string | null /** Deterministic buyer derived from the payer wallet Sign & Generate path. */ derivedBuyerAddress: string | null + /** When true, buyer private keys are also written to localStorage. */ + rememberPrivateKeysOnDevice: boolean } export interface AiCreditsWidgetAdapterActions { @@ -71,11 +71,16 @@ export interface AiCreditsWidgetAdapterActions { */ generateBuyerKey: () => Promise /** - * Switches the active buyer. Address should be in `state.buyers`. + * Switches the active buyer and reloads that buyer's account view. + * Address should be in `state.buyers`. */ - selectBuyer: (address: string) => void + selectBuyer: (address: string) => Promise discoverBuyers: (addresses: string[]) => void - importBuyerFromPrivateKey: (privateKey: string) => Promise + importBuyerFromPrivateKey: ( + privateKey: string, + options?: { rememberOnDevice?: boolean }, + ) => Promise + setRememberPrivateKeysOnDevice: (remember: boolean) => void /** * Applies an NCDI deep-link buyer assignment from URL GET parameters * (`buyerAddress` + `operatorSignature`). Selects the buyer immediately, From 1250753642e247dc3f6805656272b25608c66903 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 5 Aug 2026 16:45:36 -0400 Subject: [PATCH 15/17] refactor(ai-credits-widget): remove buyerAddress from credit history queries - Eliminated buyerAddress parameter from the credit history API request and related types, streamlining the query structure. - Updated the useAiCreditsHistory hook to reflect the removal of buyerAddress, enhancing clarity in filtering logic. - Adjusted mocked backend client to remove buyerAddress filtering, ensuring consistency across implementations. --- packages/ai-credits-widget/src/backendClient.ts | 1 - packages/ai-credits-widget/src/backendTypes.ts | 1 - .../ai-credits-widget/src/mocked/backendClient.ts | 4 ---- .../ai-credits-widget/src/useAiCreditsHistory.ts | 13 +++++++------ 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index 26487ba8..6abbd1af 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -295,7 +295,6 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient if (options.fundingStatus) params.set('fundingStatus', options.fundingStatus) if (options.from) params.set('from', options.from) if (options.to) params.set('to', options.to) - if (options.buyerAddress) params.set('buyerAddress', options.buyerAddress) const response = await fetch(`${this.accountBase(payer)}/credit-history?${params.toString()}`) if (!response.ok) throw new Error(`Credit history request failed: ${response.status}`) diff --git a/packages/ai-credits-widget/src/backendTypes.ts b/packages/ai-credits-widget/src/backendTypes.ts index 04c14e16..7aee006b 100644 --- a/packages/ai-credits-widget/src/backendTypes.ts +++ b/packages/ai-credits-widget/src/backendTypes.ts @@ -59,7 +59,6 @@ export type CreditHistoryQuery = { fundingStatus?: GdCreditEntry['fundingStatus'] from?: string to?: string - buyerAddress?: string } export type AccountView = { diff --git a/packages/ai-credits-widget/src/mocked/backendClient.ts b/packages/ai-credits-widget/src/mocked/backendClient.ts index e312608a..165a64c5 100644 --- a/packages/ai-credits-widget/src/mocked/backendClient.ts +++ b/packages/ai-credits-widget/src/mocked/backendClient.ts @@ -40,10 +40,6 @@ function paginateGdCredits( }) if (options.source) result = result.filter((entry) => entry.source === options.source) if (options.fundingStatus) result = result.filter((entry) => entry.fundingStatus === options.fundingStatus) - if (options.buyerAddress) { - const buyer = normalizeAddress(options.buyerAddress) - result = result.filter((entry) => entry.buyerAddress && normalizeAddress(entry.buyerAddress) === buyer) - } if (options.from) { const fromMs = Date.parse(options.from) result = result.filter((entry) => Date.parse(entry.createdAt) >= fromMs) diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts index de64bdb0..620d5d2d 100644 --- a/packages/ai-credits-widget/src/useAiCreditsHistory.ts +++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts @@ -162,8 +162,7 @@ export function useAiCreditsHistory(options: { const client = backendClient ?? createBackendClient(backendUrl) const apiSource = activeSources.length === 1 ? activeSources[0] : undefined const fundingStatus = statusFilter === 'all' ? undefined : statusFilter - const apiBuyerAddress = - buyerAddressFilter === BUYER_FILTER_ALL ? undefined : buyerAddressFilter + const filterByBuyer = buyerAddressFilter !== BUYER_FILTER_ALL try { const collected: GdCreditEntry[] = [] @@ -171,7 +170,11 @@ export function useAiCreditsHistory(options: { let apiHasMore = true let pages = 0 - while (pages < BUYER_FILTER_FILL_MAX_PAGES && collected.length < HISTORY_PAGE_SIZE && apiHasMore) { + while ( + pages < BUYER_FILTER_FILL_MAX_PAGES && + collected.length < HISTORY_PAGE_SIZE && + apiHasMore + ) { const response = await client.getCreditHistory(address, { limit: HISTORY_PAGE_SIZE, offset: cursor, @@ -179,7 +182,6 @@ export function useAiCreditsHistory(options: { fundingStatus, from: toIsoStartOfDay(fromDate), to: toIsoEndOfDay(toDate), - buyerAddress: apiBuyerAddress, }) const sourceFiltered = @@ -203,8 +205,7 @@ export function useAiCreditsHistory(options: { cursor = response.offset + response.limit pages += 1 - if (!apiBuyerAddress) break - if (buyerFiltered.length === response.items.length) break + if (!filterByBuyer) break } setEntries((prev) => (append ? [...prev, ...collected] : collected)) From ab11f6427b0b764bc17fc9cf9a51094234107572 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 5 Aug 2026 17:08:38 -0400 Subject: [PATCH 16/17] refactor(ai-credits-widget): remove private key handling from state and components - Eliminated rememberPrivateKeysOnDevice from state management and related components, streamlining the buyer management process. - Updated various hooks and components to reflect the removal of private key handling, enhancing clarity and reducing complexity. - Adjusted Storybook stories to align with the new state structure, ensuring consistency across the application. --- .../helpers/aiCreditsWidgetStories.tsx | 2 - packages/ai-credits-widget/src/adapter.ts | 25 +--- .../components/manage/BuyerOperatorCard.tsx | 67 +---------- .../ai-credits-widget/src/payerSession.ts | 113 ++---------------- .../src/widgetRuntimeContract.ts | 8 +- 5 files changed, 12 insertions(+), 203 deletions(-) diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 79c7bd75..b3040a7b 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -49,7 +49,6 @@ function createMockState( activeTab: 'buy', buyers: [], derivedBuyerAddress: null, - rememberPrivateKeysOnDevice: false, } return { ...base, ...overrides } } @@ -67,7 +66,6 @@ function createAdapterFactory( selectBuyer: async () => {}, discoverBuyers: () => {}, importBuyerFromPrivateKey: async () => {}, - setRememberPrivateKeysOnDevice: () => {}, applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 33547907..e118419d 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -48,8 +48,6 @@ import { upsertBuyerKey, setActiveBuyerAddress, setBuyerOperatorConsented, - setRememberPrivateKeysOnDevice, - getRememberPrivateKeysOnDevice, mergeBuyerAddressList, rememberBuyerAddresses, listKnownBuyerAddresses, @@ -116,7 +114,6 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { activeTab: 'buy', buyers: [], derivedBuyerAddress: null, - rememberPrivateKeysOnDevice: false, } const WALLET_LOADING_STATE: Partial = { @@ -440,7 +437,6 @@ export function useAiCreditsAdapter({ operatorSignature: sessionPatch.operatorSignature, operatorConsented: sessionPatch.operatorConsented, derivedBuyerAddress: sessionPatch.derivedBuyerAddress, - rememberPrivateKeysOnDevice: sessionPatch.rememberPrivateKeysOnDevice, buyers: prev.buyers, ...WALLET_LOADING_STATE, error: null, @@ -538,7 +534,6 @@ export function useAiCreditsAdapter({ { ...patch, buyers: mergeBuyerAddressList(prev.buyers, ...buyers), - rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address!), ...(account ? {} : { activeTab: 'buy' as const }), }, true, @@ -564,7 +559,6 @@ export function useAiCreditsAdapter({ ...patch, ...accountPatch, ...buyerFields, - rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address!), operatorConsented: accountPatch.operatorConsented ?? buyerFields.operatorConsented, ...(account ? {} : { activeTab: 'buy' as const }), @@ -587,7 +581,6 @@ export function useAiCreditsAdapter({ buyerPrvKey: null, operatorSignature: null, operatorConsented: false, - rememberPrivateKeysOnDevice: false, status: chainId !== null && chainId !== CELO_CHAIN_ID ? 'unsupported_chain' @@ -699,7 +692,6 @@ export function useAiCreditsAdapter({ setState((prev) => mergeStatePreservingNonBuyTab(prev, { ...buyerFields, - rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(payerAddress), error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), }), @@ -791,7 +783,7 @@ export function useAiCreditsAdapter({ ) const handleImportBuyerFromPrivateKey = useCallback( - async (rawPrivateKey: string, options?: { rememberOnDevice?: boolean }) => { + async (rawPrivateKey: string) => { if (!address) { setState((prev) => withDerivedStatus(prev, { error: 'Connect your wallet before importing a buyer key' }, true), @@ -815,9 +807,6 @@ export function useAiCreditsAdapter({ try { const privateKey = normalized as `0x${string}` const buyerAccount = privateKeyToAccount(privateKey) - if (options?.rememberOnDevice) { - setRememberPrivateKeysOnDevice(address, true) - } upsertBuyerKey(address, buyerAccount.address, { privateKey }, { setActive: true }) const buyers = mergeBuyerAddressList( @@ -828,7 +817,6 @@ export function useAiCreditsAdapter({ setState((prev) => mergeStatePreservingNonBuyTab(prev, { ...buyerFields, - rememberPrivateKeysOnDevice: getRememberPrivateKeysOnDevice(address), operatorAddress: null, totalCreditUsd: null, withdrawableUsd: null, @@ -880,15 +868,6 @@ export function useAiCreditsAdapter({ [address, backendClient, chainClient], ) - const handleSetRememberPrivateKeysOnDevice = useCallback( - (remember: boolean) => { - if (!address) return - setRememberPrivateKeysOnDevice(address, remember) - setState((prev) => ({ ...prev, rememberPrivateKeysOnDevice: remember })) - }, - [address], - ) - const resolveBuyerList = useCallback( (payer: string, preferredBuyer?: string | null) => resolveLocalBuyers(payer, preferredBuyer), [], @@ -1705,7 +1684,6 @@ export function useAiCreditsAdapter({ selectBuyer: handleSelectBuyer, discoverBuyers: handleDiscoverBuyers, importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, - setRememberPrivateKeysOnDevice: handleSetRememberPrivateKeysOnDevice, applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, @@ -1726,7 +1704,6 @@ export function useAiCreditsAdapter({ handleSelectBuyer, handleDiscoverBuyers, handleImportBuyerFromPrivateKey, - handleSetRememberPrivateKeysOnDevice, handleApplyDeepLinkBuyer, handleSignOperatorConsent, handleSyncOperatorConsentFromChain, diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index ac54d1f5..7e9d0dcc 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -15,14 +15,12 @@ interface BuyerOperatorCardProps { | 'operatorConsented' | 'operatorConsentPending' | 'buyers' - | 'rememberPrivateKeysOnDevice' > actions: Pick< AiCreditsWidgetAdapterActions, | 'generateBuyerKey' | 'selectBuyer' | 'importBuyerFromPrivateKey' - | 'setRememberPrivateKeysOnDevice' | 'signOperatorConsent' > } @@ -93,21 +91,17 @@ function BuyerImportPanel({ onImportPrivateKey, onClose, }: { - onImportPrivateKey: ( - key: string, - options?: { rememberOnDevice?: boolean }, - ) => Promise + onImportPrivateKey: (key: string) => Promise onClose: () => void }) { const [inputValue, setInputValue] = useState('') - const [rememberOnDevice, setRememberOnDevice] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) async function handleSubmit() { if (!inputValue.trim()) return setIsSubmitting(true) try { - await onImportPrivateKey(inputValue.trim(), { rememberOnDevice }) + await onImportPrivateKey(inputValue.trim()) setInputValue('') onClose() } finally { @@ -127,31 +121,6 @@ function BuyerImportPanel({ placeholder="0x…" autoFocus /> - setRememberOnDevice((prev) => !prev)} - > - - {rememberOnDevice ? : null} - - - Remember on this device (localStorage). Keys otherwise stay in this tab only. - - - - actions.setRememberPrivateKeysOnDevice(!rememberPrivateKeysOnDevice) - } - > - - {rememberPrivateKeysOnDevice ? ( - - ) : null} - - - {rememberPrivateKeysOnDevice - ? 'Remembered on this device (localStorage).' - : 'Stored in this browser tab only (clears when the tab closes).'} - - )} diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index 534bda47..96299f7f 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -9,7 +9,6 @@ export type PayerWalletSession = { knownBuyers: string[] activeBuyerAddress: string | null derivedBuyerAddress: string | null - rememberPrivateKeysOnDevice: boolean } export type BuyerStateFields = { @@ -23,7 +22,6 @@ export type BuyerStateFields = { const MEMORY_SESSIONS = new Map() const STORAGE_KEY_PREFIX = 'goodwidget.ai-credits.payerSession.' -const PRIVATE_KEYS_SESSION_PREFIX = 'goodwidget.ai-credits.privateKeys.' function payerSessionKey(address: string): string { return address.toLowerCase() @@ -37,10 +35,6 @@ function canUseLocalStorage(): boolean { return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' } -function canUseSessionStorage(): boolean { - return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined' -} - function formatBuyerAddress(address: string): string { const key = normalizeBuyerAddress(address) return key.startsWith('0x') ? `0x${key.slice(2)}` : key @@ -52,7 +46,6 @@ function emptySession(): PayerWalletSession { knownBuyers: [], activeBuyerAddress: null, derivedBuyerAddress: null, - rememberPrivateKeysOnDevice: false, } } @@ -66,73 +59,6 @@ function isBuyerKeyEntry(value: unknown): value is BuyerKeyEntry { ) } -function extractPrivateKeys(session: PayerWalletSession): Record { - const keys: Record = {} - for (const [address, entry] of Object.entries(session.buyerKeys)) { - if (entry.privateKey) keys[address] = entry.privateKey - } - return keys -} - -function stripPrivateKeys(session: PayerWalletSession): PayerWalletSession { - const buyerKeys: Record = {} - for (const [address, entry] of Object.entries(session.buyerKeys)) { - buyerKeys[address] = { - operatorSignature: entry.operatorSignature, - operatorConsented: entry.operatorConsented, - } - } - return { ...session, buyerKeys } -} - -function mergePrivateKeyMap( - session: PayerWalletSession, - privateKeys: Record, -): PayerWalletSession { - if (Object.keys(privateKeys).length === 0) return session - const buyerKeys = { ...session.buyerKeys } - for (const [address, privateKey] of Object.entries(privateKeys)) { - const key = normalizeBuyerAddress(address) - buyerKeys[key] = { - ...buyerKeys[key], - privateKey, - } - } - return { ...session, buyerKeys } -} - -function readPrivateKeysFromSessionStorage(payer: string): Record { - if (!canUseSessionStorage()) return {} - try { - const raw = window.sessionStorage.getItem(`${PRIVATE_KEYS_SESSION_PREFIX}${payerSessionKey(payer)}`) - if (!raw) return {} - const parsed = JSON.parse(raw) as Record - const result: Record = {} - for (const [address, value] of Object.entries(parsed)) { - if (typeof value === 'string' && value) { - result[normalizeBuyerAddress(address)] = value - } - } - return result - } catch { - return {} - } -} - -function writePrivateKeysToSessionStorage(payer: string, privateKeys: Record): void { - if (!canUseSessionStorage()) return - try { - const key = `${PRIVATE_KEYS_SESSION_PREFIX}${payerSessionKey(payer)}` - if (Object.keys(privateKeys).length === 0) { - window.sessionStorage.removeItem(key) - return - } - window.sessionStorage.setItem(key, JSON.stringify(privateKeys)) - } catch { - return - } -} - function migrateLegacySession(raw: Record): PayerWalletSession { const session = emptySession() const known = new Set() @@ -219,10 +145,6 @@ function migrateLegacySession(raw: Record): PayerWalletSession } } - if (typeof raw.rememberPrivateKeysOnDevice === 'boolean') { - session.rememberPrivateKeysOnDevice = raw.rememberPrivateKeysOnDevice - } - if (typeof raw.operatorConsented === 'boolean' && raw.operatorConsented && session.activeBuyerAddress) { const active = session.activeBuyerAddress session.buyerKeys[active] = { @@ -246,16 +168,11 @@ function parseStoredSession(raw: string): PayerWalletSession | null { function persistSession(address: string, session: PayerWalletSession): void { MEMORY_SESSIONS.set(payerSessionKey(address), session) - - const privateKeys = extractPrivateKeys(session) - writePrivateKeysToSessionStorage(address, privateKeys) - if (!canUseLocalStorage()) return try { - const forDisk = session.rememberPrivateKeysOnDevice ? session : stripPrivateKeys(session) window.localStorage.setItem( `${STORAGE_KEY_PREFIX}${payerSessionKey(address)}`, - JSON.stringify(forDisk), + JSON.stringify(session), ) } catch { return @@ -268,24 +185,22 @@ export function readPayerSession(address: string | null): PayerWalletSession | n const cached = MEMORY_SESSIONS.get(key) if (cached) return cached - let session: PayerWalletSession | null = null if (canUseLocalStorage()) { try { const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${key}`) if (raw) { - session = parseStoredSession(raw) + const parsed = parseStoredSession(raw) + if (parsed) { + MEMORY_SESSIONS.set(key, parsed) + return parsed + } } } catch { - session = null + return null } } - if (!session) return null - - const fromSessionStorage = readPrivateKeysFromSessionStorage(address) - session = mergePrivateKeyMap(session, fromSessionStorage) - MEMORY_SESSIONS.set(key, session) - return session + return null } export function patchPayerSession(address: string, patch: Partial): void { @@ -312,15 +227,6 @@ export function setBuyerOperatorConsented( upsertBuyerKey(payer, buyerAddress, { operatorConsented }, { setActive: false }) } -export function setRememberPrivateKeysOnDevice(payer: string, remember: boolean): void { - const existing = readPayerSession(payer) ?? emptySession() - persistSession(payer, { ...existing, rememberPrivateKeysOnDevice: remember }) -} - -export function getRememberPrivateKeysOnDevice(payer: string): boolean { - return readPayerSession(payer)?.rememberPrivateKeysOnDevice ?? false -} - export function upsertBuyerKey( payer: string, buyerAddress: string, @@ -454,7 +360,6 @@ export function patchPayerSessionFields(address: string | null): { operatorConsented: boolean activeBuyerAddress: string | null derivedBuyerAddress: string | null - rememberPrivateKeysOnDevice: boolean } { const session = readPayerSession(address) if (!session) { @@ -465,7 +370,6 @@ export function patchPayerSessionFields(address: string | null): { operatorConsented: false, activeBuyerAddress: null, derivedBuyerAddress: null, - rememberPrivateKeysOnDevice: false, } } @@ -479,7 +383,6 @@ export function patchPayerSessionFields(address: string | null): { operatorConsented: Boolean(entry?.operatorConsented), activeBuyerAddress: active, derivedBuyerAddress: session.derivedBuyerAddress, - rememberPrivateKeysOnDevice: session.rememberPrivateKeysOnDevice, } } diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index a0ad8c7b..2a699e04 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -58,8 +58,6 @@ export interface AiCreditsWidgetAdapterState { buyers: string[] /** Deterministic buyer derived from the payer wallet Sign & Generate path. */ derivedBuyerAddress: string | null - /** When true, buyer private keys are also written to localStorage. */ - rememberPrivateKeysOnDevice: boolean } export interface AiCreditsWidgetAdapterActions { @@ -76,11 +74,7 @@ export interface AiCreditsWidgetAdapterActions { */ selectBuyer: (address: string) => Promise discoverBuyers: (addresses: string[]) => void - importBuyerFromPrivateKey: ( - privateKey: string, - options?: { rememberOnDevice?: boolean }, - ) => Promise - setRememberPrivateKeysOnDevice: (remember: boolean) => void + importBuyerFromPrivateKey: (privateKey: string) => Promise /** * Applies an NCDI deep-link buyer assignment from URL GET parameters * (`buyerAddress` + `operatorSignature`). Selects the buyer immediately, From 4f69871ae131828407c81f9ef97cae769b80ef16 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 5 Aug 2026 17:29:37 -0400 Subject: [PATCH 17/17] refactor(ai-credits-widget): streamline session management by removing in-memory storage - Eliminated MEMORY_SESSIONS map to simplify session handling. - Updated readPayerSession function to directly access local storage, improving efficiency and reducing complexity. - Adjusted persistSession function to remove unnecessary session caching logic, enhancing clarity in session persistence. --- .../ai-credits-widget/src/payerSession.ts | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index 96299f7f..50d9ecb5 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -20,7 +20,6 @@ export type BuyerStateFields = { derivedBuyerAddress: string | null } -const MEMORY_SESSIONS = new Map() const STORAGE_KEY_PREFIX = 'goodwidget.ai-credits.payerSession.' function payerSessionKey(address: string): string { @@ -167,7 +166,6 @@ function parseStoredSession(raw: string): PayerWalletSession | null { } function persistSession(address: string, session: PayerWalletSession): void { - MEMORY_SESSIONS.set(payerSessionKey(address), session) if (!canUseLocalStorage()) return try { window.localStorage.setItem( @@ -180,27 +178,16 @@ function persistSession(address: string, session: PayerWalletSession): void { } export function readPayerSession(address: string | null): PayerWalletSession | null { - if (!address) return null - const key = payerSessionKey(address) - const cached = MEMORY_SESSIONS.get(key) - if (cached) return cached - - if (canUseLocalStorage()) { - try { - const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${key}`) - if (raw) { - const parsed = parseStoredSession(raw) - if (parsed) { - MEMORY_SESSIONS.set(key, parsed) - return parsed - } - } - } catch { - return null - } + if (!address || !canUseLocalStorage()) return null + try { + const raw = window.localStorage.getItem( + `${STORAGE_KEY_PREFIX}${payerSessionKey(address)}`, + ) + if (!raw) return null + return parseStoredSession(raw) + } catch { + return null } - - return null } export function patchPayerSession(address: string, patch: Partial): void {