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 719e04a2..7b5bf8e3 100644 --- a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx @@ -17,6 +17,9 @@ import { BackendUnavailableStory, UnsupportedChainStory, AppKitConnectWalletStory, + MultiBuyerManageStory, + DeepLinkBuyerStory, + MultiBuyerHistoryStory, } from '../helpers/aiCreditsWidgetStories' const meta: Meta = { @@ -91,6 +94,21 @@ export const UnsupportedChain: Story = { render: () => , } +/** Multi-buyer manage tab: buyer selector and private-key reveal. */ +export const MultiBuyerManage: Story = { + render: () => , +} + +/** Deep-link partner buyer: consent via pre-signed operatorSignature. */ +export const DeepLinkBuyer: 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 55ab2454..b3040a7b 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -34,7 +34,9 @@ function createMockState( isGoodIdVerified: false, buyerPubKey: null, buyerPrvKey: null, + operatorSignature: null, operatorConsented: false, + operatorConsentPending: false, operatorAddress: null, minDepositUsd: '1.00', minStreamUsd: '1.00', @@ -45,6 +47,8 @@ function createMockState( streamBonusPercent: 20, error: null, activeTab: 'buy', + buyers: [], + derivedBuyerAddress: null, } return { ...base, ...overrides } } @@ -59,6 +63,10 @@ function createAdapterFactory( connect: async () => {}, switchChain: async () => {}, generateBuyerKey: async () => {}, + selectBuyer: async () => {}, + discoverBuyers: () => {}, + importBuyerFromPrivateKey: async () => {}, + applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, buildQuote: async (depositG, streamG) => ({ @@ -412,3 +420,82 @@ export function InjectedWalletStory() { ) } + +// --------------------------------------------------------------------------- +// Multi-buyer fixture stories +// --------------------------------------------------------------------------- + +const BUYER_WALLET = { + address: '0xfc128652c9b397a1f89A9EC84E798B869B0E4c7a' as const, + privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001' as const, +} + +const BUYER_IMPORTED = { + address: '0xAbcDef1234567890AbcDef1234567890AbcDef12' as const, + privateKey: '0x0000000000000000000000000000000000000000000000000000000000000002' as const, +} + +const BUYER_PARTNER = { + address: '0x1111111111111111111111111111111111111111' as const, + operatorSignature: + '0x1111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200' as const, +} + +/** Multi-buyer manage: backend address list with one selected buyer that has a local key. */ +export function MultiBuyerManageStory() { + return ( + + ) +} + +/** Deep-link partner buyer: consent uses pre-signed operatorSignature (no private key). */ +export function DeepLinkBuyerStory() { + 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 2f469681..4d2bb055 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -185,6 +185,15 @@ function BuyCreditsPanel({ state, actions, isPending, onPay }: BuyPanelProps) { } else { content = ( <> + {state.error && ( + + + Deep link unavailable + + {state.error} + + )} + {state.address && ( ) : state.activeTab === 'history' ? ( - + ({ address }))} + /> ) : ( buyPanel )} diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index f311339e..e118419d 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -17,6 +17,7 @@ import { normalizeChannelId, signRequestClose, signWithdrawPrincipal } from './b import { totalCreditUsdFromProfile, buildAccountView, + collectBuyerAddressesFromEntries, createBackendClient, DEFAULT_DISCOUNT_CONFIG, enrichAccountView, @@ -32,10 +33,25 @@ import { import type { AiCreditsChainClient } from './chainClient' import { signOperatorConsentFromTypedData } from './operatorConsent' import { - addressesMatch, + clearDeepLinkArtifacts, + deepLinkManualFallbackMessage, + isValidBuyerAddress, + isValidOperatorSignature, + resolveDeepLinkParams, + storeDeepLinkParams, + type DeepLinkParams, +} from './deepLinkParams' +import { + buildBuyerStateFields, patchPayerSessionFields, - patchPayerSession, readPayerSession, + upsertBuyerKey, + setActiveBuyerAddress, + setBuyerOperatorConsented, + mergeBuyerAddressList, + rememberBuyerAddresses, + listKnownBuyerAddresses, + getBuyerKeyEntry, } from './payerSession' import { executeCeloPayment, G_TOKEN_CELO_ADDRESS, isStreamAmountChanged } from './celoPayment' import { startGoodIdVerification, isUserRejectedWalletRequest } from './goodIdVerification' @@ -83,7 +99,9 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { isGoodIdVerified: false, buyerPubKey: null, buyerPrvKey: null, + operatorSignature: null, operatorConsented: false, + operatorConsentPending: false, operatorAddress: null, minDepositUsd: null, minStreamUsd: null, @@ -94,6 +112,8 @@ const INITIAL_STATE: AiCreditsWidgetAdapterState = { streamBonusPercent: DEFAULT_DISCOUNT_CONFIG.streamBonusPercent, error: null, activeTab: 'buy', + buyers: [], + derivedBuyerAddress: null, } const WALLET_LOADING_STATE: Partial = { @@ -109,6 +129,52 @@ const WALLET_LOADING_STATE: Partial = { operatorAddress: null, } +const BUYER_HISTORY_LOOKUP_LIMIT = 100 + +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 +): Promise { + let historyBuyers: string[] = [] + try { + const history = await backend.getCreditHistory(payer, { + limit: BUYER_HISTORY_LOOKUP_LIMIT, + offset: 0, + }) + historyBuyers = collectBuyerAddressesFromEntries(history.items) + } catch { + historyBuyers = [] + } + return rememberBuyerAddresses(payer, [...historyBuyers, ...extras]) +} + +function selectPreferredBuyer( + buyers: string[], + preferredBuyer?: string | null, +): string | null { + if ( + preferredBuyer && + buyers.some((item) => item.toLowerCase() === preferredBuyer.toLowerCase()) + ) { + return preferredBuyer + } + return buyers[0] ?? preferredBuyer ?? null +} + function isNonBuyTab(tab: AiCreditsWidgetTab): boolean { return tab === 'manage' || tab === 'history' } @@ -255,42 +321,16 @@ 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> { - 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) - - return { - buyerPubKey, - buyerPrvKey, - operatorConsented, } } -function syncOperatorConsentSession(address: string, operatorConsented: boolean | undefined): void { - if (operatorConsented === undefined) return - patchPayerSession(address, { operatorConsented }) -} - -function syncBuyerPubKeySession(address: string, buyerPubKey: string | null | undefined): void { - if (!buyerPubKey) return - const existing = readPayerSession(address) - if (existing?.buyerPubKey) return - patchPayerSession(address, { buyerPubKey }) +function activateBuyerSelection( + payer: string, + buyers: string[], + selectedAddress: string | null, +) { + setActiveBuyerAddress(payer, selectedAddress) + return buildBuyerStateFields(payer, buyers, selectedAddress) } export interface UseAiCreditsAdapterOptions { @@ -336,6 +376,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 @@ -384,14 +427,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, + derivedBuyerAddress: sessionPatch.derivedBuyerAddress, + buyers: prev.buyers, ...WALLET_LOADING_STATE, error: null, status: 'connecting', @@ -416,14 +462,21 @@ export function useAiCreditsAdapter({ }), ]) - const accountPromise = buildAccountView(address!, backendClient, chainClient, { - buyerAddress: sessionPatch.buyerPubKey ?? 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 @@ -435,15 +488,29 @@ export function useAiCreditsAdapter({ const gdUsdPerTokenPromise = chainClient.fetchGdUsdPerToken().catch(() => null) const discountConfigPromise = backendClient.getDiscountConfig().catch(() => null) + const buyersPromise = pendingDeepLink + ? Promise.resolve( + rememberBuyerAddresses(address!, [ + preferredBuyer, + ...listKnownBuyerAddresses(address!), + ]), + ) + : discoverBuyersFromHistory( + address!, + backendClient, + preferredBuyer, + ...listKnownBuyerAddresses(address!), + ) try { - const [[rawBalance, decimals], account, minimums, gdUsdPerToken, discountConfig] = + const [[rawBalance, decimals], account, minimums, gdUsdPerToken, discountConfig, buyers] = await Promise.all([ balancePromise, accountPromise, minimumsPromise, gdUsdPerTokenPromise, discountConfigPromise, + buyersPromise, ]) if (cancelled) return @@ -460,43 +527,60 @@ export function useAiCreditsAdapter({ discountConfig?.streamBonusPercent ?? DEFAULT_DISCOUNT_CONFIG.streamBonusPercent, } - 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) - } - if (address && account?.view.buyer) { - syncBuyerPubKeySession(address, account.view.buyer) - } - return withDerivedStatus( + if (pendingDeepLink || deepLinkApplyInFlightRef.current) { + setState((prev) => + withDerivedStatus( + prev, + { + ...patch, + buyers: mergeBuyerAddressList(prev.buyers, ...buyers), + ...(account ? {} : { activeTab: 'buy' as const }), + }, + true, + ), + ) + return + } + + const selectedBuyer = selectPreferredBuyer(buyers, preferredBuyer) + const accountPatch = account + ? viewToStatePatch(account.view, account.enriched, INITIAL_STATE, { + balanceMode: 'always', + }) + : {} + if (selectedBuyer && accountPatch.operatorConsented !== undefined) { + setBuyerOperatorConsented(address!, selectedBuyer, accountPatch.operatorConsented) + } + const buyerFields = activateBuyerSelection(address!, buyers, selectedBuyer) + setState((prev) => + withDerivedStatus( prev, { ...patch, ...accountPatch, ...buyerFields, + operatorConsented: + accountPatch.operatorConsented ?? buyerFields.operatorConsented, ...(account ? {} : { activeTab: 'buy' as const }), }, true, - ) - }) + ), + ) } 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: [], + derivedBuyerAddress: null, + buyerPubKey: null, + buyerPrvKey: null, + operatorSignature: null, + operatorConsented: false, status: chainId !== null && chainId !== CELO_CHAIN_ID ? 'unsupported_chain' @@ -542,6 +626,10 @@ export function useAiCreditsAdapter({ }) }, []) + /** + * 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 handleGenerateBuyerKey = useCallback(async () => { if (!address || !providerRef.current) { setState((prev) => @@ -554,8 +642,28 @@ export function useAiCreditsAdapter({ return } + const payerAddress = address as Address + const session = readPayerSession(payerAddress) + const derivedAddress = session?.derivedBuyerAddress ?? null + const existingKey = derivedAddress ? getBuyerKeyEntry(payerAddress, derivedAddress) : null + + if (derivedAddress && existingKey?.privateKey) { + 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 + } + try { - const payerAddress = address as Address const message = buildBuyerKeyMessage(payerAddress) const walletClient = createWalletClient({ account: payerAddress, @@ -567,17 +675,23 @@ export function useAiCreditsAdapter({ message, }) const privateKey = deriveBuyerPrivateKeyFromSignature(signature) - const account = privateKeyToAccount(privateKey) + const buyerAccount = privateKeyToAccount(privateKey) - patchPayerSession(payerAddress, { - buyerPubKey: account.address, - buyerPrvKey: privateKey, - }) + upsertBuyerKey( + payerAddress, + buyerAccount.address, + { privateKey }, + { setActive: true, setDerived: true }, + ) + const buyers = mergeBuyerAddressList( + listKnownBuyerAddresses(payerAddress), + buyerAccount.address, + ) + const buyerFields = buildBuyerStateFields(payerAddress, buyers, buyerAccount.address) setState((prev) => mergeStatePreservingNonBuyTab(prev, { - buyerPubKey: account.address, - buyerPrvKey: privateKey, + ...buyerFields, error: null, ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), }), @@ -595,9 +709,335 @@ export function useAiCreditsAdapter({ } }, [address]) + const handleSelectBuyer = useCallback( + async (buyerAddress: string) => { + if (!address) return + 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, + totalGdDepositedG: null, + 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, backendClient, chainClient], + ) + + const handleDiscoverBuyers = useCallback( + (addresses: string[]) => { + if (!address || addresses.length === 0) return + const buyers = rememberBuyerAddresses(address, addresses) + setState((prev) => { + const sameLength = buyers.length === prev.buyers.length + const unchanged = + sameLength && + buyers.every( + (item, index) => item.toLowerCase() === prev.buyers[index]?.toLowerCase(), + ) + if (unchanged) return prev + return { ...prev, buyers } + }) + }, + [address], + ) + + 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) + upsertBuyerKey(address, buyerAccount.address, { privateKey }, { setActive: true }) + + const buyers = mergeBuyerAddressList( + listKnownBuyerAddresses(address), + buyerAccount.address, + ) + const buyerFields = buildBuyerStateFields(address, buyers, buyerAccount.address) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + 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, backendClient, chainClient], + ) + + const resolveBuyerList = useCallback( + (payer: string, preferredBuyer?: string | null) => resolveLocalBuyers(payer, preferredBuyer), + [], + ) + + /** + * 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, operatorSignature: string) => { + if (!address) { + return + } + + const trimmedAddress = buyerAddress.trim() + const trimmedSignature = operatorSignature.trim() + + if (!isValidBuyerAddress(trimmedAddress)) { + setState((prev) => + withDerivedStatus( + prev, + { + error: deepLinkManualFallbackMessage('Deep-link buyerAddress is invalid.'), + activeTab: 'buy', + status: 'purchase_setup', + }, + true, + ), + ) + return + } + + if (!isValidOperatorSignature(trimmedSignature)) { + setState((prev) => + withDerivedStatus( + prev, + { + error: deepLinkManualFallbackMessage('Deep-link operatorSignature is invalid.'), + activeTab: 'buy', + status: 'purchase_setup', + }, + true, + ), + ) + return + } + + storeDeepLinkParams({ + buyerAddress: trimmedAddress, + operatorSignature: trimmedSignature, + }) + + upsertBuyerKey( + address, + trimmedAddress, + { operatorSignature: trimmedSignature }, + { setActive: true }, + ) + + const buyers = mergeBuyerAddressList(listKnownBuyerAddresses(address), trimmedAddress) + const buyerFields = buildBuyerStateFields(address, buyers, trimmedAddress) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + operatorAddress: null, + totalCreditUsd: null, + withdrawableUsd: null, + totalGdDepositedG: null, + monthlyStreamG: null, + activeTab: 'buy', + operatorConsentPending: true, + 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) + } + + 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, + { + ...accountPatch, + ...nextBuyerFields, + operatorConsented: true, + operatorConsentPending: false, + activeTab: 'buy', + error: null, + }, + true, + ), + ) + clearDeepLinkArtifacts() + } catch (err: unknown) { + setState((prev) => + withDerivedStatus( + prev, + { + error: deepLinkManualFallbackMessage( + err instanceof Error + ? err.message + : 'Could not apply deep-link operator approval.', + ), + activeTab: 'buy', + status: 'purchase_setup', + operatorConsentPending: false, + }, + true, + ), + ) + } + }, + [address, backendClient, chainClient, resolveBuyerList], + ) + 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 keyEntry = getBuyerKeyEntry(currentState.address, currentState.buyerPubKey) + const storedOperatorSignature = + currentState.operatorSignature ?? keyEntry?.operatorSignature ?? null + + if (!currentState.buyerPrvKey && !storedOperatorSignature) { setState((prev) => withDerivedStatus( prev, @@ -608,9 +1048,17 @@ export function useAiCreditsAdapter({ return } + if (currentState.operatorConsentPending) return + const ref: AccountRef = { payer: currentState.address, buyer: currentState.buyerPubKey } const onNonBuyTab = isNonBuyTab(currentState.activeTab) + setState((prev) => ({ + ...prev, + operatorConsentPending: true, + error: null, + })) + try { const operatorStatus = await chainClient.getBuyerOperatorStatus(ref) @@ -619,10 +1067,18 @@ export function useAiCreditsAdapter({ } if (operatorStatus.operatorAccepted) { - patchPayerSession(currentState.address, { operatorConsented: true }) + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, true) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) setState((prev) => mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, operatorConsented: true, + operatorConsentPending: false, error: null, ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), }), @@ -630,16 +1086,23 @@ export function useAiCreditsAdapter({ return } - const payload = await chainClient.buildOperatorConsentPayload(ref, operatorStatus) + let buyerSig: `0x${string}` + if (currentState.buyerPrvKey) { + 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, + ) + } 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, { nonce: operatorStatus.consentNonce, @@ -647,10 +1110,18 @@ export function useAiCreditsAdapter({ }) await waitForOperatorConsent(chainClient, ref) - patchPayerSession(currentState.address, { operatorConsented: true }) + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, true) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) setState((prev) => mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, operatorConsented: true, + operatorConsentPending: false, error: null, ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), }), @@ -658,10 +1129,11 @@ export function useAiCreditsAdapter({ } catch (err: unknown) { setState((prev) => ({ ...prev, + operatorConsentPending: false, error: err instanceof Error ? err.message : 'Operator consent signature rejected', })) } - }, [state, backendClient, chainClient]) + }, [state, backendClient, chainClient, resolveBuyerList]) const handleSyncOperatorConsentFromChain = useCallback(async () => { const currentState = state @@ -674,7 +1146,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, { @@ -832,8 +1304,16 @@ export function useAiCreditsAdapter({ const creditUsdMicro = (BigInt(totalCreditUsd) - BigInt(balanceBefore || '0')).toString() + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) + setState((prev) => withDerivedStatus(prev, { + ...buyerFields, totalCreditUsd, error: null, activeTab: 'manage', @@ -869,6 +1349,7 @@ export function useAiCreditsAdapter({ celoVault, onPaySuccess, onPayError, + resolveBuyerList, prepareSettlement, skipVaultPaymentValidation, ], @@ -880,34 +1361,36 @@ export function useAiCreditsAdapter({ if (!currentState.address) return try { - const sessionBuyer = - currentState.buyerPubKey ?? - patchPayerSessionFields(currentState.address).buyerPubKey ?? - null + const preferredBuyer = currentState.buyerPubKey + const buyerList = resolveBuyerList(currentState.address, preferredBuyer) const [view, discountConfig] = await Promise.all([ buildAccountView(currentState.address, backendClient, chainClient, { - buyerAddress: sessionBuyer, + buyerAddress: preferredBuyer, }), backendClient.getDiscountConfig().catch(() => null), ]) 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 sessionFields = mergeSessionFields( - prev, - patchPayerSessionFields(currentState.address), - accountPatch, - false, - ) - 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' @@ -918,7 +1401,9 @@ export function useAiCreditsAdapter({ { ...prev, status: statusSeed }, { ...accountPatch, - ...sessionFields, + ...buyerFields, + operatorConsented: + accountPatch.operatorConsented ?? buyerFields.operatorConsented, activeTab: prev.activeTab, error: null, depositBonusPercent: @@ -937,7 +1422,7 @@ export function useAiCreditsAdapter({ ) } }, - [state, backendClient, chainClient], + [state, backendClient, chainClient, resolveBuyerList], ) const handleVerifyGoodId = useCallback(async (): Promise => { @@ -1138,11 +1623,68 @@ export function useAiCreditsAdapter({ handleSetActiveTab('buy') }, [handleSetActiveTab]) + useEffect(() => { + if (typeof window === 'undefined' || deepLinkParseDoneRef.current) return + deepLinkParseDoneRef.current = true + + const parsed = resolveDeepLinkParams(window.location.search) + if (parsed.status === 'absent') return + + if (parsed.status === 'partial') { + const missing = + parsed.present === 'buyerAddress' ? 'operatorSignature' : 'buyerAddress' + setState((prev) => + withDerivedStatus( + prev, + { + error: deepLinkManualFallbackMessage(`Deep link is missing ${missing}.`), + activeTab: 'buy', + status: 'purchase_setup', + }, + false, + ), + ) + return + } + + if (parsed.status === 'invalid') { + setState((prev) => + withDerivedStatus( + prev, + { + error: deepLinkManualFallbackMessage(`${parsed.reason}.`), + activeTab: 'buy', + status: 'purchase_setup', + }, + false, + ), + ) + return + } + + 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( () => ({ connect: handleConnect, switchChain: handleSwitchChain, generateBuyerKey: handleGenerateBuyerKey, + selectBuyer: handleSelectBuyer, + discoverBuyers: handleDiscoverBuyers, + importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, + applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, buildQuote: handleBuildQuote, @@ -1159,6 +1701,10 @@ export function useAiCreditsAdapter({ handleConnect, handleSwitchChain, handleGenerateBuyerKey, + handleSelectBuyer, + handleDiscoverBuyers, + handleImportBuyerFromPrivateKey, + handleApplyDeepLinkBuyer, handleSignOperatorConsent, handleSyncOperatorConsentFromChain, handleBuildQuote, diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index d5f0bd54..6abbd1af 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -135,6 +135,19 @@ export function resolveBuyerAddress(entries: GdCreditEntry[]): string | null { return null } +export function collectBuyerAddressesFromEntries(entries: GdCreditEntry[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const entry of entries) { + if (!entry.buyerAddress || !isAddress(entry.buyerAddress)) continue + const key = normalizeAddress(entry.buyerAddress) + if (seen.has(key)) continue + seen.add(key) + result.push(key) + } + return result +} + function defaultOperatorStatus(payer: string): BuyerOperatorStatus { const account = normalizeAddress(payer) return { @@ -491,16 +504,14 @@ 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 sessionBuyer = + const buyer = options.buyerAddress && isAddress(options.buyerAddress) ? normalizeAddress(options.buyerAddress) : null - const buyer = sessionBuyer ?? resolveBuyerAddress(history.items) const [operator, withdrawableUsd] = buyer ? await Promise.all([ chain.getBuyerOperatorStatus({ payer: normalizedPayer, buyer }), 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/components/buy/OperatorConsentStep.tsx b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx index 0e9dfec9..bfde0344 100644 --- a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx +++ b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx @@ -1,11 +1,13 @@ -import React, { useState } from 'react' +import React from 'react' import { Button, ButtonText, Card, Heading, Icon, Spinner, Text, XStack, YStack } from '@goodwidget/ui' import { truncateAddress, compactButtonProps } from '../shared/styles' interface OperatorConsentStepProps { buyerPubKey: string | null buyerPrvKey: string | null + operatorSignature?: string | null operatorConsented: boolean + operatorConsentPending?: boolean onSign: () => Promise embedded?: boolean } @@ -13,12 +15,14 @@ interface OperatorConsentStepProps { export function OperatorConsentStep({ buyerPubKey, buyerPrvKey, + operatorSignature = null, operatorConsented, + operatorConsentPending = false, onSign, embedded = false, }: OperatorConsentStepProps) { - const [isSigning, setIsSigning] = useState(false) - const canSign = Boolean(buyerPubKey && buyerPrvKey) + const canSign = Boolean(buyerPubKey && (buyerPrvKey || operatorSignature)) + const isBusy = operatorConsentPending const Shell = embedded ? YStack : Card @@ -50,16 +54,13 @@ export function OperatorConsentStep({ size="sm" {...compactButtonProps} onPress={() => { - setIsSigning(true) - void onSign().finally(() => { - setIsSigning(false) - }) + void onSign() }} - disabled={!canSign || isSigning} + disabled={!canSign || isBusy} > - {isSigning ? ( + {isBusy ? ( - Signing… + Submitting… ) : ( diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx index 05d1b399..dfa92011 100644 --- a/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx +++ b/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx @@ -36,6 +36,7 @@ export function AiCreditsFlowStepper({ ) { return 'active' } + if (step === 'consent' && state.operatorConsentPending) return 'active' return 'ready' } @@ -49,7 +50,9 @@ export function AiCreditsFlowStepper({ { id: 'consent', title: 'Operator Consent', - description: 'Sign permission for the AntseedBuyerOperator', + description: state.operatorConsentPending + ? 'Submitting operator consent…' + : 'Sign permission for the AntseedBuyerOperator', status: getStepStatus('consent'), }, { diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx index 001ddbee..7ba2220a 100644 --- a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx +++ b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx @@ -129,7 +129,9 @@ export function AiCreditsPurchaseFlow({ embedded buyerPubKey={state.buyerPubKey} buyerPrvKey={state.buyerPrvKey ?? null} + operatorSignature={state.operatorSignature ?? null} operatorConsented={state.operatorConsented} + operatorConsentPending={state.operatorConsentPending} 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..079ed2c3 100644 --- a/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts +++ b/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts @@ -1,13 +1,19 @@ import type { AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract' import type { AiCreditsFlowStep } from './types' +function isSelectedDerivedBuyer(state: AiCreditsWidgetAdapterState): boolean { + if (!state.buyerPubKey || !state.derivedBuyerAddress) return false + return state.buyerPubKey.toLowerCase() === state.derivedBuyerAddress.toLowerCase() +} + export function mapStatusToActiveStep( state: AiCreditsWidgetAdapterState, 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 (isSelectedDerivedBuyer(state) && state.buyerPrvKey && !buyerPubKeySaved) return 'buyer_key' if (!state.operatorConsented) return 'consent' if ( state.status === 'purchase_setup' || @@ -40,6 +46,7 @@ export function getActiveFlowStepActionLabel( if (!buyerPubKeySaved) return "Continue Buyer Key" return 'View Buyer Key' case 'consent': + if (state.operatorConsentPending) return 'Submitting Consent…' return state.operatorConsented ? 'View Operator Consent' : 'Sign Operator Consent' case 'pay': return 'Set Amounts & Pay' diff --git a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx index 27e52b69..d73bae6e 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..7e9d0dcc 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -1,5 +1,5 @@ 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 { AddressView } from '../shared/AddressView' import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles' @@ -8,17 +8,160 @@ import { useCopyFeedback } from '../shared/useCopyFeedback' interface BuyerOperatorCardProps { state: Pick< AiCreditsWidgetAdapterState, - 'address' | 'buyerPubKey' | 'buyerPrvKey' | 'operatorConsented' + | 'address' + | 'buyerPubKey' + | 'buyerPrvKey' + | 'operatorSignature' + | 'operatorConsented' + | 'operatorConsentPending' + | 'buyers' > - actions: Pick + actions: Pick< + AiCreditsWidgetAdapterActions, + | 'generateBuyerKey' + | 'selectBuyer' + | 'importBuyerFromPrivateKey' + | 'signOperatorConsent' + > +} + +function shortAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function BuyerSelector({ + buyers, + activeBuyerAddress, + onSelect, +}: { + buyers: string[] + activeBuyerAddress: string | null + onSelect: (address: string) => void +}) { + if (buyers.length <= 1) return null + + return ( + + + Buyers + + + {buyers.map((buyer) => { + const isActive = buyer.toLowerCase() === activeBuyerAddress?.toLowerCase() + return ( + { + if (!isActive) void onSelect(buyer) + }} + > + + {shortAddress(buyer)} + + {isActive && } + + ) + })} + + + ) +} + +function BuyerImportPanel({ + onImportPrivateKey, + onClose, +}: { + onImportPrivateKey: (key: string) => Promise + onClose: () => void +}) { + const [inputValue, setInputValue] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleSubmit() { + if (!inputValue.trim()) return + setIsSubmitting(true) + try { + await onImportPrivateKey(inputValue.trim()) + setInputValue('') + onClose() + } finally { + setIsSubmitting(false) + } + } + + return ( + + + Paste private key (0x…) + + + + + + + + ) } export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { - const { address, buyerPubKey, buyerPrvKey, operatorConsented } = state + const { + address, + buyerPubKey, + buyerPrvKey, + operatorSignature, + operatorConsented, + operatorConsentPending, + buyers, + } = 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 buyerCanSign = Boolean(buyerPrvKey || operatorSignature) return ( @@ -27,6 +170,12 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { {address && } {buyerPubKey && } + + @@ -50,12 +199,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 || !buyerPrvKey} + disabled={operatorConsented || operatorConsentPending || !buyerCanSign} > - {isSigning ? ( + {operatorConsentPending ? ( ) : ( {operatorConsented ? 'Consented' : 'Sign Consent'} @@ -63,6 +211,23 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { + {showImport ? ( + setShowImport(false)} + /> + ) : ( + + )} + {buyerPrvKey && ( @@ -107,4 +272,3 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { ) } - diff --git a/packages/ai-credits-widget/src/deepLinkParams.ts b/packages/ai-credits-widget/src/deepLinkParams.ts new file mode 100644 index 00000000..7a9480d0 --- /dev/null +++ b/packages/ai-credits-widget/src/deepLinkParams.ts @@ -0,0 +1,149 @@ +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; 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 = + 'Generate or import a buyer key manually to continue.' + +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 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 { + 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', + source: 'url', + value: { + buyerAddress, + operatorSignature, + }, + } +} + +/** + * 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) + 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) +} + +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 451978c7..b889c57a 100644 --- a/packages/ai-credits-widget/src/index.ts +++ b/packages/ai-credits-widget/src/index.ts @@ -14,8 +14,23 @@ export type { AiCreditsPaySuccessDetail, AiCreditsPayErrorDetail, AiCreditsQuote, + BuyerKeyEntry, } from './widgetRuntimeContract' +export { + parseDeepLinkParams, + resolveDeepLinkParams, + isValidBuyerAddress, + isValidOperatorSignature, + storeDeepLinkParams, + readStoredDeepLinkParams, + clearStoredDeepLinkParams, + clearDeepLinkArtifacts, + deepLinkManualFallbackMessage, + DEEP_LINK_MANUAL_FALLBACK_HINT, +} from './deepLinkParams' +export type { DeepLinkParams, DeepLinkParseResult } from './deepLinkParams' + export type { AiCreditsBackendClient, AccountRef, diff --git a/packages/ai-credits-widget/src/mocked/backendClient.ts b/packages/ai-credits-widget/src/mocked/backendClient.ts index 7be81b93..165a64c5 100644 --- a/packages/ai-credits-widget/src/mocked/backendClient.ts +++ b/packages/ai-credits-widget/src/mocked/backendClient.ts @@ -106,7 +106,10 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { const key = normalizeAddress(payer) if (!this.accountStates.has(key)) { this.accountStates.set(key, { - principalUsd: 0n, bonusUsd: 0n, transactions: createDemoHistory(key), rootAccount: key, + principalUsd: 0n, + bonusUsd: 0n, + transactions: createDemoHistory(key), + rootAccount: key, }) } return this.accountStates.get(key)! @@ -124,10 +127,16 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { .filter((entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed') .reduce((sum, entry) => sum + BigInt(entry.totalCreditUsd), 0n) return { - account: normalizeAddress(payer), rootAccount: state.rootAccount, createdAt: now, updatedAt: now, - totalGdDepositedWei: '0', totalPrincipalUsd: state.principalUsd.toString(), - totalBonusUsd: state.bonusUsd.toString(), totalGDStreamedWei: '0', - totalOutstandingFundingUsd: outstanding.toString(), streamFlowRateWeiPerSecond: '0', + account: normalizeAddress(payer), + rootAccount: state.rootAccount, + createdAt: now, + updatedAt: now, + totalGdDepositedWei: '0', + totalPrincipalUsd: state.principalUsd.toString(), + totalBonusUsd: state.bonusUsd.toString(), + totalGDStreamedWei: '0', + totalOutstandingFundingUsd: outstanding.toString(), + streamFlowRateWeiPerSecond: '0', } } @@ -203,10 +212,16 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { return { account: buyer, amountUsd: body.amount, bridge: { enabled: true, txHash: '0xmock' } } } - async submitOperatorConsent(buyer: string): Promise { + async submitOperatorConsent( + buyer: string, + _body: { nonce: string; signature: string }, + ): Promise { await sleep(MOCK_DELAY_MS) const normalizedBuyer = normalizeAddress(buyer) markMockOperatorConsent(normalizedBuyer) - return { buyer: normalizedBuyer, bridge: { enabled: true, txHash: '0xmock' } } + return { + buyer: normalizedBuyer, + bridge: { enabled: true, txHash: '0xmock' }, + } } } diff --git a/packages/ai-credits-widget/src/payerSession.ts b/packages/ai-credits-widget/src/payerSession.ts index 336c273a..50d9ecb5 100644 --- a/packages/ai-credits-widget/src/payerSession.ts +++ b/packages/ai-credits-widget/src/payerSession.ts @@ -1,45 +1,375 @@ +export type BuyerKeyEntry = { + privateKey?: string + operatorSignature?: string + operatorConsented?: boolean +} + export type PayerWalletSession = { - buyerPubKey?: string - buyerPrvKey?: string + buyerKeys: Record + knownBuyers: string[] + activeBuyerAddress: string | null + derivedBuyerAddress: string | null +} + +export type BuyerStateFields = { + buyers: string[] + buyerPubKey: string | null + buyerPrvKey: string | null + operatorSignature: string | null operatorConsented: boolean + derivedBuyerAddress: string | null } -const payerWalletSessions = new Map() +const STORAGE_KEY_PREFIX = 'goodwidget.ai-credits.payerSession.' function payerSessionKey(address: string): string { return address.toLowerCase() } +function normalizeBuyerAddress(address: string): string { + return address.toLowerCase() +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} + +function formatBuyerAddress(address: string): string { + const key = normalizeBuyerAddress(address) + return key.startsWith('0x') ? `0x${key.slice(2)}` : key +} + +function emptySession(): PayerWalletSession { + return { + buyerKeys: {}, + knownBuyers: [], + activeBuyerAddress: null, + derivedBuyerAddress: null, + } +} + +function isBuyerKeyEntry(value: unknown): value is BuyerKeyEntry { + if (!value || typeof value !== 'object') return false + const entry = value as Record + return ( + (entry.privateKey === undefined || typeof entry.privateKey === 'string') && + (entry.operatorSignature === undefined || typeof entry.operatorSignature === 'string') && + (entry.operatorConsented === undefined || typeof entry.operatorConsented === 'boolean') + ) +} + +function migrateLegacySession(raw: Record): PayerWalletSession { + const session = emptySession() + const known = new Set() + + const trackKnown = (address: string | null | undefined) => { + if (!address) return + known.add(normalizeBuyerAddress(address)) + } + + if (typeof raw.buyerPubKey === 'string' && raw.buyerPubKey) { + const address = normalizeBuyerAddress(raw.buyerPubKey) + session.activeBuyerAddress = address + session.derivedBuyerAddress = address + trackKnown(address) + if (typeof raw.buyerPrvKey === 'string' && raw.buyerPrvKey) { + session.buyerKeys[address] = { privateKey: raw.buyerPrvKey } + } + } + + if (Array.isArray(raw.buyers)) { + for (const item of raw.buyers) { + if (typeof item === 'string' && item) { + trackKnown(item) + continue + } + if (!item || typeof item !== 'object') continue + const buyer = item as Record + if (typeof buyer.address !== 'string' || !buyer.address) continue + const address = normalizeBuyerAddress(buyer.address) + trackKnown(address) + const entry: BuyerKeyEntry = {} + if (typeof buyer.privateKey === 'string' && buyer.privateKey) { + entry.privateKey = buyer.privateKey + } + if (typeof buyer.operatorSignature === 'string' && buyer.operatorSignature) { + entry.operatorSignature = buyer.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, + } + } + if (buyer.type === 'derived' && entry.privateKey) { + session.derivedBuyerAddress = address + } + } + } + + if (Array.isArray(raw.knownBuyers)) { + for (const item of raw.knownBuyers) { + if (typeof item === 'string' && item) trackKnown(item) + } + } + + if (typeof raw.activeBuyerAddress === 'string' && raw.activeBuyerAddress) { + session.activeBuyerAddress = normalizeBuyerAddress(raw.activeBuyerAddress) + trackKnown(session.activeBuyerAddress) + } + if (typeof raw.derivedBuyerAddress === 'string' && raw.derivedBuyerAddress) { + session.derivedBuyerAddress = normalizeBuyerAddress(raw.derivedBuyerAddress) + trackKnown(session.derivedBuyerAddress) + } + if (typeof raw.operatorSignature === 'string' && raw.operatorSignature && session.activeBuyerAddress) { + const active = session.activeBuyerAddress + session.buyerKeys[active] = { + ...session.buyerKeys[active], + operatorSignature: raw.operatorSignature, + } + } + + if (raw.buyerKeys && typeof raw.buyerKeys === 'object') { + for (const [address, entry] of Object.entries(raw.buyerKeys as Record)) { + if (!isBuyerKeyEntry(entry)) continue + const key = normalizeBuyerAddress(address) + trackKnown(key) + session.buyerKeys[key] = { + ...session.buyerKeys[key], + ...entry, + } + } + } + + 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 +} + +function parseStoredSession(raw: string): PayerWalletSession | null { + try { + const parsed = JSON.parse(raw) as Record + return migrateLegacySession(parsed) + } catch { + return null + } +} + +function persistSession(address: string, session: PayerWalletSession): void { + if (!canUseLocalStorage()) return + try { + window.localStorage.setItem( + `${STORAGE_KEY_PREFIX}${payerSessionKey(address)}`, + JSON.stringify(session), + ) + } catch { + return + } +} + export function readPayerSession(address: string | null): PayerWalletSession | null { - if (!address) return null - return payerWalletSessions.get(payerSessionKey(address)) ?? 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 + } } export function patchPayerSession(address: string, patch: Partial): void { - const existing = readPayerSession(address) - payerWalletSessions.set(payerSessionKey(address), { - operatorConsented: false, + const existing = readPayerSession(address) ?? emptySession() + persistSession(address, { ...existing, ...patch, + buyerKeys: patch.buyerKeys ?? existing.buyerKeys, + knownBuyers: patch.knownBuyers ?? existing.knownBuyers, }) } +export function getBuyerKeyEntry(payer: string, buyerAddress: string): BuyerKeyEntry | null { + const session = readPayerSession(payer) + if (!session) return null + return session.buyerKeys[normalizeBuyerAddress(buyerAddress)] ?? null +} + +export function setBuyerOperatorConsented( + payer: string, + buyerAddress: string, + operatorConsented: boolean, +): void { + upsertBuyerKey(payer, buyerAddress, { operatorConsented }, { setActive: false }) +} + +export function upsertBuyerKey( + payer: string, + buyerAddress: string, + entry: BuyerKeyEntry, + options?: { setActive?: boolean; setDerived?: boolean }, +): PayerWalletSession { + const existing = readPayerSession(payer) ?? emptySession() + const key = normalizeBuyerAddress(buyerAddress) + const previous = existing.buyerKeys[key] ?? {} + const knownBuyers = mergeBuyerAddressList(existing.knownBuyers, key) + const next: PayerWalletSession = { + ...existing, + knownBuyers, + buyerKeys: { + ...existing.buyerKeys, + [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, + } + persistSession(payer, next) + return next +} + +export function setActiveBuyerAddress(payer: string, buyerAddress: string | null): PayerWalletSession { + const existing = readPayerSession(payer) ?? emptySession() + const normalized = buyerAddress ? normalizeBuyerAddress(buyerAddress) : null + const next: PayerWalletSession = { + ...existing, + knownBuyers: normalized + ? mergeBuyerAddressList(existing.knownBuyers, normalized) + : existing.knownBuyers, + activeBuyerAddress: normalized, + } + persistSession(payer, next) + return next +} + +export function mergeBuyerAddressList( + existingBuyers: string[], + ...extras: Array +): string[] { + const seen = new Set() + const result: string[] = [] + + for (const address of [...existingBuyers, ...extras]) { + if (!address) continue + const key = normalizeBuyerAddress(address) + if (seen.has(key)) continue + seen.add(key) + result.push(formatBuyerAddress(key)) + } + + return result +} + +export function normalizeBuyerAddressList( + buyers: Array | undefined | null, +): string[] { + if (!buyers || buyers.length === 0) return [] + const seen = new Set() + const result: string[] = [] + for (const item of buyers) { + const address = typeof item === 'string' ? item : item.address + if (!address) continue + const key = normalizeBuyerAddress(address) + if (seen.has(key)) continue + seen.add(key) + result.push(formatBuyerAddress(key)) + } + return result +} + +export function listKnownBuyerAddresses(payer: string): string[] { + const session = readPayerSession(payer) + if (!session) return [] + return mergeBuyerAddressList( + session.knownBuyers, + ...Object.keys(session.buyerKeys), + session.activeBuyerAddress, + session.derivedBuyerAddress, + ) +} + +export function rememberBuyerAddresses( + payer: string, + addresses: Array, +): string[] { + const existing = readPayerSession(payer) ?? emptySession() + const knownBuyers = mergeBuyerAddressList(existing.knownBuyers, ...addresses) + if ( + knownBuyers.length === existing.knownBuyers.length && + knownBuyers.every( + (address, index) => address.toLowerCase() === existing.knownBuyers[index]?.toLowerCase(), + ) + ) { + return listKnownBuyerAddresses(payer) + } + persistSession(payer, { ...existing, knownBuyers }) + 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 + buyerPubKey: string | null buyerPrvKey: string | null + operatorSignature: string | null operatorConsented: boolean + activeBuyerAddress: string | null + derivedBuyerAddress: string | null } { const session = readPayerSession(address) if (!session) { return { + buyerPubKey: null, buyerPrvKey: null, + operatorSignature: null, operatorConsented: false, + activeBuyerAddress: null, + derivedBuyerAddress: null, } } + + const active = session.activeBuyerAddress + const entry = active ? session.buyerKeys[normalizeBuyerAddress(active)] : undefined + return { - buyerPubKey: session.buyerPubKey, - buyerPrvKey: session.buyerPrvKey ?? null, - operatorConsented: session.operatorConsented, + buyerPubKey: active, + buyerPrvKey: entry?.privateKey ?? null, + operatorSignature: entry?.operatorSignature ?? null, + operatorConsented: Boolean(entry?.operatorConsented), + activeBuyerAddress: active, + derivedBuyerAddress: session.derivedBuyerAddress, } } diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts index f63dfc28..620d5d2d 100644 --- a/packages/ai-credits-widget/src/useAiCreditsHistory.ts +++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts @@ -6,10 +6,14 @@ 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'] +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 @@ -55,9 +59,15 @@ 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 + buyerAddressFilter: BuyerAddressFilter fromDate: string toDate: string entries: GdCreditEntry[] @@ -72,6 +82,7 @@ export interface AiCreditsHistoryState { export interface AiCreditsHistoryActions { setSourceChecked: (source: CreditHistorySource, checked: boolean) => void setStatusFilter: (status: CreditHistoryStatusFilter) => void + setBuyerAddressFilter: (value: BuyerAddressFilter) => void setFromDate: (value: string) => void setToDate: (value: string) => void reload: () => Promise @@ -86,14 +97,24 @@ export interface UseAiCreditsHistoryResult { export function useAiCreditsHistory(options: { address: string | null backendUrl?: string + defaultBuyerFilter?: BuyerAddressFilter environment?: AiCreditsWidgetEnvironment backendClient?: AiCreditsBackendClient + onBuyersDiscovered?: (addresses: string[]) => void }): UseAiCreditsHistoryResult { - const { address, backendUrl, environment = 'production', backendClient } = options + const { + address, + backendUrl, + defaultBuyerFilter = BUYER_FILTER_ALL, + environment = 'production', + backendClient, + onBuyersDiscovered, + } = 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([]) @@ -103,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], @@ -137,24 +162,55 @@ export function useAiCreditsHistory(options: { const client = backendClient ?? createBackendClient(backendUrl) const apiSource = activeSources.length === 1 ? activeSources[0] : undefined const fundingStatus = statusFilter === 'all' ? undefined : statusFilter + const filterByBuyer = buyerAddressFilter !== BUYER_FILTER_ALL try { - const response = await client.getCreditHistory(address, { - limit: HISTORY_PAGE_SIZE, - offset: nextOffset, - source: apiSource, - fundingStatus, - from: toIsoStartOfDay(fromDate), - to: toIsoEndOfDay(toDate), - }) - const pageItems = - activeSources.length === 1 - ? response.items - : response.items.filter((entry) => selectedSources[entry.source]) - - setEntries((prev) => (append ? [...prev, ...pageItems] : pageItems)) - setOffset(nextOffset) - setHasMore(response.hasMore) + 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), + }) + + 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 (!filterByBuyer) break + } + + setEntries((prev) => (append ? [...prev, ...collected] : collected)) + setOffset(cursor) + setHasMore(apiHasMore) } catch (err: unknown) { if (!append) setEntries([]) setHasMore(false) @@ -171,9 +227,11 @@ export function useAiCreditsHistory(options: { backendClient, activeSources, statusFilter, + buyerAddressFilter, fromDate, toDate, selectedSources, + onBuyersDiscovered, ], ) @@ -195,13 +253,14 @@ export function useAiCreditsHistory(options: { }, [loadHistory]) const loadMore = useCallback(async () => { - await loadHistory(offset + HISTORY_PAGE_SIZE, true) + await loadHistory(offset, true) }, [loadHistory, offset]) return { state: { selectedSources, statusFilter, + buyerAddressFilter, fromDate, toDate, entries, @@ -215,6 +274,7 @@ export function useAiCreditsHistory(options: { actions: { setSourceChecked, setStatusFilter, + setBuyerAddressFilter, setFromDate, setToDate, reload, 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.' diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index 65a06de8..2a699e04 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -25,6 +25,9 @@ export interface AiCreditsQuote { streamAmountG: string } +/** Re-export for consumers that don't want to import from payerSession directly. */ +export type { BuyerKeyEntry } from './payerSession' + export interface AiCreditsWidgetAdapterState { status: AiCreditsWidgetStatus address: string | null @@ -33,9 +36,15 @@ export interface AiCreditsWidgetAdapterState { gdUsdPerToken: number | null totalCreditUsd: string | null isGoodIdVerified: boolean + /** Active buyer public address. */ buyerPubKey: string | null + /** Active buyer private key from the local per-payer key map. */ buyerPrvKey: string | null + /** 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 @@ -46,12 +55,33 @@ export interface AiCreditsWidgetAdapterState { streamBonusPercent: number error: string | null activeTab: AiCreditsWidgetTab + buyers: string[] + /** Deterministic buyer derived from the payer wallet Sign & Generate path. */ + derivedBuyerAddress: string | null } export interface AiCreditsWidgetAdapterActions { connect: () => Promise switchChain: () => Promise + /** + * Creates or restores the single deterministic buyer for this payer wallet. + * If a derived key already exists locally, selects that buyer without re-signing. + */ generateBuyerKey: () => Promise + /** + * Switches the active buyer and reloads that buyer's account view. + * Address should be in `state.buyers`. + */ + selectBuyer: (address: string) => Promise + discoverBuyers: (addresses: string[]) => void + importBuyerFromPrivateKey: (privateKey: string) => Promise + /** + * Applies an NCDI deep-link buyer assignment from URL GET parameters + * (`buyerAddress` + `operatorSignature`). Selects the buyer immediately, + * 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 syncOperatorConsentFromChain: () => Promise buildQuote: (depositG: string, streamG: string) => Promise @@ -119,3 +149,4 @@ export interface AiCreditsWidgetProps { adapterOptions?: AiCreditsWidgetAdapterOptions testId?: string } + diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts index fa540a23..6e6f3de5 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -236,3 +236,76 @@ 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', + 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 + +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() + + await expect(root.getByText(/0xfc12/i)).toBeVisible() + await expect(root.getByText(/0xAbcD|0xabcd/i)).toBeVisible() + await expect(root.getByText(/0x1111/i)).toBeVisible() + await expect(root.getByRole('button', { name: /Sign & Generate/i })).toBeVisible() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-15-multi-buyer-manage.png', + fullPage: true, + }) +}) + +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() + + const signConsentButton = root.getByRole('button', { name: /Sign Consent/i }) + await expect(signConsentButton).toBeEnabled() + + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-16-deep-link-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() + + 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 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() + + 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-key-link.png', + fullPage: true, + }) +}) +