diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 0266d812..dc159335 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -74,6 +74,7 @@ function createAdapterFactory( importBuyerFromPrivateKey: async () => overrides.buyerPubKey ?? null, applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, + revokeOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, buildQuote: async (depositG, streamG) => ({ depositAmountG: depositG, @@ -265,6 +266,9 @@ export function ManageTabStory() { totalCreditUsd: '110000000', totalBonusUsd: '10000000', buyerPubKey: '0xfc128652c9b397a1f89A9EC84E798B869B0E4c7a', + // Unauthorizing is signed locally, so the Manage story needs the signer key + // for the Signer Key card to offer it. + buyerPrvKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', operatorConsented: true, operatorAddress: '0x0000000000000000000000000000000000000004', totalGdDepositedG: '50.00', diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index cc62248e..75a286ab 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -14,7 +14,12 @@ import { } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { buildBuyerKeyMessage, deriveBuyerPrivateKeyFromSignature } from './buyerKeyDerivation' -import { normalizeChannelId, signRequestClose, signWithdrawPrincipal } from './buyerSignatures' +import { + normalizeChannelId, + signRequestClose, + signRevokeOperator, + signWithdrawPrincipal, +} from './buyerSignatures' import { totalCreditUsdFromProfile, buildAccountView, @@ -166,10 +171,7 @@ async function discoverBuyersFromHistory( return rememberBuyerAddresses(payer, [...historyBuyers, ...extras]) } -function selectPreferredBuyer( - buyers: string[], - preferredBuyer?: string | null, -): string | null { +function selectPreferredBuyer(buyers: string[], preferredBuyer?: string | null): string | null { if ( preferredBuyer && buyers.some((item) => item.toLowerCase() === preferredBuyer.toLowerCase()) @@ -391,11 +393,7 @@ function viewToStatePatch( } } -function activateBuyerSelection( - payer: string, - buyers: string[], - selectedAddress: string | null, -) { +function activateBuyerSelection(payer: string, buyers: string[], selectedAddress: string | null) { setActiveBuyerAddress(payer, selectedAddress) return buildBuyerStateFields(payer, buyers, selectedAddress) } @@ -436,9 +434,7 @@ export function useAiCreditsAdapter({ const { address, chainId, isConnected, provider, connect, switchChain } = useWallet() const [state, setState] = useState(INITIAL_STATE) const configurationError = - backendClientOverride || backendUrl - ? null - : 'AI Credits backend is not configured' + backendClientOverride || backendUrl ? null : 'AI Credits backend is not configured' const providerRef = useRef(null) providerRef.current = provider as EIP1193Provider | null @@ -571,13 +567,12 @@ export function useAiCreditsAdapter({ })) .catch(() => null) - const minimumsPromise = - skipVaultPaymentValidation - ? Promise.resolve({ - minDepositUsd: '1.00', - minStreamUsd: '1.00', - }) - : fetchVaultPaymentMinimums(publicClient, celoVault, address as Address).catch(() => null) + const minimumsPromise = skipVaultPaymentValidation + ? Promise.resolve({ + minDepositUsd: '1.00', + minStreamUsd: '1.00', + }) + : fetchVaultPaymentMinimums(publicClient, celoVault, address as Address).catch(() => null) const gdUsdPerTokenPromise = chainClient.fetchGdUsdPerToken().catch(() => null) const discountConfigPromise = backendClient.getDiscountConfig().catch(() => null) @@ -688,8 +683,8 @@ export function useAiCreditsAdapter({ ...balanceStalledStatus(prev), ...accountPatch, ...buyerFields, - operatorConsented: - accountPatch.operatorConsented ?? buyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? buyerFields.operatorConsented, + ...(account ? {} : { activeTab: 'buy' as const }), }, true, ), @@ -796,10 +791,7 @@ export function useAiCreditsAdapter({ const existingKey = derivedAddress ? getBuyerKeyEntry(payerAddress, derivedAddress) : null if (derivedAddress && existingKey?.privateKey) { - const buyers = mergeBuyerAddressList( - listKnownBuyerAddresses(payerAddress), - derivedAddress, - ) + const buyers = mergeBuyerAddressList(listKnownBuyerAddresses(payerAddress), derivedAddress) const buyerFields = activateBuyerSelection(payerAddress, buyers, derivedAddress) setState((prev) => mergeStatePreservingNonBuyTab(prev, { @@ -897,8 +889,7 @@ export function useAiCreditsAdapter({ mergeStatePreservingNonBuyTab(prev, { ...accountPatch, ...nextBuyerFields, - operatorConsented: - accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, error: null, }), ) @@ -921,9 +912,7 @@ export function useAiCreditsAdapter({ const sameLength = buyers.length === prev.buyers.length const unchanged = sameLength && - buyers.every( - (item, index) => item.toLowerCase() === prev.buyers[index]?.toLowerCase(), - ) + buyers.every((item, index) => item.toLowerCase() === prev.buyers[index]?.toLowerCase()) if (unchanged) return prev return { ...prev, buyers } }) @@ -935,7 +924,11 @@ export function useAiCreditsAdapter({ async (rawPrivateKey: string): Promise => { if (!address) { setState((prev) => - withDerivedStatus(prev, { error: 'Connect your wallet before importing a signer key' }, true), + withDerivedStatus( + prev, + { error: 'Connect your wallet before importing a buyer key' }, + true, + ), ) return null } @@ -958,10 +951,7 @@ export function useAiCreditsAdapter({ const buyerAccount = privateKeyToAccount(privateKey) upsertBuyerKey(address, buyerAccount.address, { privateKey }, { setActive: true }) - const buyers = mergeBuyerAddressList( - listKnownBuyerAddresses(address), - buyerAccount.address, - ) + const buyers = mergeBuyerAddressList(listKnownBuyerAddresses(address), buyerAccount.address) const buyerFields = buildBuyerStateFields(address, buyers, buyerAccount.address) setState((prev) => mergeStatePreservingNonBuyTab(prev, { @@ -986,17 +976,9 @@ export function useAiCreditsAdapter({ balanceMode: 'always', }) if (accountPatch.operatorConsented !== undefined) { - setBuyerOperatorConsented( - address, - buyerAccount.address, - accountPatch.operatorConsented, - ) + setBuyerOperatorConsented(address, buyerAccount.address, accountPatch.operatorConsented) } - const nextBuyerFields = buildBuyerStateFields( - address, - buyers, - buyerAccount.address, - ) + const nextBuyerFields = buildBuyerStateFields(address, buyers, buyerAccount.address) setState((prev) => mergeStatePreservingNonBuyTab(prev, { ...accountPatch, @@ -1013,7 +995,11 @@ export function useAiCreditsAdapter({ return buyerAccount.address } catch { setState((prev) => - withDerivedStatus(prev, { error: 'Could not derive an account from the provided private key' }, true), + withDerivedStatus( + prev, + { error: 'Could not derive an account from the provided private key' }, + true, + ), ) return null } @@ -1257,6 +1243,104 @@ export function useAiCreditsAdapter({ } }, [state, chainClient]) + const handleRevokeOperatorConsent = useCallback(async () => { + const currentState = state + if (!currentState.address || !currentState.buyerPubKey || !currentState.operatorConsented) { + return + } + + if (!currentState.buyerPrvKey) { + setState((prev) => ({ + ...prev, + error: 'Signer private key missing', + })) + return + } + + if (!fundingVaultAddress) { + setState((prev) => ({ + ...prev, + error: 'Funding vault address is not configured', + })) + 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) + + if (!operatorStatus.enabled) { + throw new Error('Operator consent is not available') + } + + if (!operatorStatus.operatorAccepted) { + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, false) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + operatorConsented: false, + operatorConsentPending: false, + error: null, + ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), + }), + ) + return + } + + const nonce = await chainClient.getBuyerAuthNonce(currentState.buyerPubKey as Address) + const signature = await signRevokeOperator({ + buyerPrivateKey: currentState.buyerPrvKey as `0x${string}`, + fundingVaultAddress, + buyer: currentState.buyerPubKey as Address, + nonce: nonce, + }) + + // No confirmation poll here: the backend's operator-revoke handler awaits the Base + // receipt before responding, so a resolved request already means the tx mined — and + // ethers surfaces a revert as a throw, which reaches us as a non-2xx. + await backendClient.revokeOperatorConsent(ref.buyer, { nonce: nonce.toString(), signature }) + + setBuyerOperatorConsented(currentState.address, currentState.buyerPubKey, false) + const buyerList = resolveBuyerList(currentState.address, currentState.buyerPubKey) + const buyerFields = buildBuyerStateFields( + currentState.address, + buyerList.buyers, + buyerList.selected, + ) + setState((prev) => + mergeStatePreservingNonBuyTab(prev, { + ...buyerFields, + operatorConsented: false, + operatorConsentPending: false, + error: null, + ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), + }), + ) + } catch (err: unknown) { + setState((prev) => ({ + ...prev, + operatorConsentPending: false, + error: err instanceof Error ? err.message : 'Operator revoke failed', + })) + } + }, [state, backendClient, chainClient, fundingVaultAddress, resolveBuyerList]) + const handleBuildQuote = useCallback( async (depositG: string, streamG: string): Promise => { const quote = await chainClient.buildQuote(depositG, streamG) @@ -1358,10 +1442,15 @@ export function useAiCreditsAdapter({ } if (prepareSettlement) { - const creditUsdMicro = quoteTotalUsdMicro(quote, gdUsdPerToken, currentState.isGoodIdVerified, { - depositBonusPercent: currentState.depositBonusPercent, - streamBonusPercent: currentState.streamBonusPercent, - }) + const creditUsdMicro = quoteTotalUsdMicro( + quote, + gdUsdPerToken, + currentState.isGoodIdVerified, + { + depositBonusPercent: currentState.depositBonusPercent, + streamBonusPercent: currentState.streamBonusPercent, + }, + ) prepareSettlement(accountRef, creditUsdMicro) } @@ -1507,8 +1596,7 @@ export function useAiCreditsAdapter({ { ...accountPatch, ...buyerFields, - operatorConsented: - accountPatch.operatorConsented ?? buyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? buyerFields.operatorConsented, activeTab: prev.activeTab, // An unread balance keeps the one already on screen. ...(gBalance !== null ? { gBalance } : {}), @@ -1632,15 +1720,20 @@ export function useAiCreditsAdapter({ } try { - const timestamp = Math.floor(Date.now() / 1000) + const buyer = currentState.buyerPubKey + if (!buyer) { + setState((prev) => ({ ...prev, error: 'Select a buyer before closing a channel' })) + return + } + const nonce = await chainClient.getBuyerAuthNonce(buyer) const signature = await signRequestClose({ buyerPrivateKey: currentState.buyerPrvKey as `0x${string}`, fundingVaultAddress, channelId, - timestamp, + nonce, }) - await backendClient.closeChannel(channelId, { timestamp, signature }) + await backendClient.closeChannel(channelId, { nonce: nonce.toString(), signature }) setState((prev) => ({ ...prev, error: null })) } catch (err: unknown) { setState((prev) => ({ @@ -1649,7 +1742,7 @@ export function useAiCreditsAdapter({ })) } }, - [state, backendClient, fundingVaultAddress], + [state, backendClient, chainClient, fundingVaultAddress], ) const handleWithdrawCredits = useCallback( @@ -1690,20 +1783,20 @@ export function useAiCreditsAdapter({ const buyer = currentState.buyerPubKey as Address const payer = currentState.address as Address - const timestamp = Math.floor(Date.now() / 1000) + const nonce = await chainClient.getBuyerAuthNonce(buyer) const signature = await signWithdrawPrincipal({ buyerPrivateKey: currentState.buyerPrvKey as `0x${string}`, fundingVaultAddress, buyer, amountMicro: BigInt(amount), recipient: payer, - timestamp, + nonce, }) await backendClient.withdrawCredits(buyer, { amount, recipient: payer, - timestamp, + nonce: nonce.toString(), signature, }) setState((prev) => ({ ...prev, error: null })) @@ -1715,7 +1808,7 @@ export function useAiCreditsAdapter({ })) } }, - [state, backendClient, fundingVaultAddress, handleRefresh], + [state, backendClient, chainClient, fundingVaultAddress, handleRefresh], ) const handleRetry = useCallback(async () => { @@ -1757,8 +1850,7 @@ export function useAiCreditsAdapter({ if (parsed.status === 'absent') return if (parsed.status === 'partial') { - const missing = - parsed.present === 'buyerAddress' ? 'operatorSignature' : 'buyerAddress' + const missing = parsed.present === 'buyerAddress' ? 'operatorSignature' : 'buyerAddress' setState((prev) => withDerivedStatus( prev, @@ -1812,6 +1904,7 @@ export function useAiCreditsAdapter({ importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, + revokeOperatorConsent: handleRevokeOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, buildQuote: handleBuildQuote, pay: handlePay, @@ -1832,6 +1925,7 @@ export function useAiCreditsAdapter({ handleImportBuyerFromPrivateKey, handleApplyDeepLinkBuyer, handleSignOperatorConsent, + handleRevokeOperatorConsent, handleSyncOperatorConsentFromChain, handleBuildQuote, handlePay, diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index 269a17e7..f5801531 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -52,12 +52,12 @@ function normalizeAddress(address: string): string { export type WithdrawPrincipalRequest = { amount: string recipient: string - timestamp: number + nonce: string signature: string } export type ChannelOperationRequest = { - timestamp?: number + nonce?: string signature?: string } @@ -88,6 +88,13 @@ export type OperatorConsentResponse = { bridge: BridgeResponse } +export type OperatorRevokeRequest = { + nonce: string + signature: string +} + +export type OperatorRevokeResponse = OperatorConsentResponse + async function readBridgeResponseBody( response: Response, actionLabel: string, @@ -228,6 +235,10 @@ export interface AiCreditsBackendClient { buyer: string, body: OperatorConsentRequest, ): Promise + revokeOperatorConsent( + buyer: string, + body: OperatorRevokeRequest, + ): Promise } const BPS_PER_PERCENT = 100 @@ -428,6 +439,25 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient bridge: payload.bridge, } } + + async revokeOperatorConsent( + buyer: string, + body: OperatorRevokeRequest, + ): Promise { + const response = await fetch(`${this.accountBase(buyer)}/operator-revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + nonce: body.nonce, + signature: body.signature, + }), + }) + const payload = await readBridgeResponseBody(response, 'Operator revoke') + return { + buyer: normalizeAddress(payload.buyer ?? buyer), + bridge: payload.bridge, + } + } } export class UnavailableAiCreditsBackendClient implements AiCreditsBackendClient { @@ -474,6 +504,10 @@ export class UnavailableAiCreditsBackendClient implements AiCreditsBackendClient async submitOperatorConsent() { return this.unavailable() } + + async revokeOperatorConsent() { + return this.unavailable() + } } export function createBackendClient( diff --git a/packages/ai-credits-widget/src/buyerSignatures.ts b/packages/ai-credits-widget/src/buyerSignatures.ts index 1cc0a815..04a5218f 100644 --- a/packages/ai-credits-widget/src/buyerSignatures.ts +++ b/packages/ai-credits-widget/src/buyerSignatures.ts @@ -12,14 +12,21 @@ const WITHDRAW_PRINCIPAL_TYPES = { { name: 'buyer', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'recipient', type: 'address' }, - { name: 'timestamp', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, ], } as const const REQUEST_CLOSE_TYPES = { RequestClose: [ { name: 'channelId', type: 'bytes32' }, - { name: 'timestamp', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + ], +} as const + +const REVOKE_OPERATOR_TYPES = { + RevokeOperator: [ + { name: 'buyer', type: 'address' }, + { name: 'nonce', type: 'uint256' }, ], } as const @@ -35,7 +42,7 @@ export async function signWithdrawPrincipal(params: { buyer: Address amountMicro: bigint recipient: Address - timestamp: number + nonce: bigint }): Promise { const account = privateKeyToAccount(params.buyerPrivateKey) return account.signTypedData({ @@ -51,7 +58,7 @@ export async function signWithdrawPrincipal(params: { buyer: params.buyer, amount: params.amountMicro, recipient: params.recipient, - timestamp: BigInt(params.timestamp), + nonce: params.nonce, }, }) } @@ -60,7 +67,7 @@ export async function signRequestClose(params: { buyerPrivateKey: Hex fundingVaultAddress: Address channelId: Hex - timestamp: number + nonce: bigint }): Promise { const account = privateKeyToAccount(params.buyerPrivateKey) return account.signTypedData({ @@ -74,7 +81,30 @@ export async function signRequestClose(params: { primaryType: 'RequestClose', message: { channelId: params.channelId, - timestamp: BigInt(params.timestamp), + nonce: params.nonce, + }, + }) +} + +export async function signRevokeOperator(params: { + buyerPrivateKey: Hex + fundingVaultAddress: Address + buyer: Address + nonce: bigint +}): Promise { + const account = privateKeyToAccount(params.buyerPrivateKey) + return account.signTypedData({ + domain: { + name: ANTSEED_BUYER_OPERATOR_DOMAIN.name, + version: ANTSEED_BUYER_OPERATOR_DOMAIN.version, + chainId: BASE_CHAIN_ID, + verifyingContract: params.fundingVaultAddress, + }, + types: REVOKE_OPERATOR_TYPES, + primaryType: 'RevokeOperator', + message: { + buyer: params.buyer, + nonce: params.nonce, }, }) } diff --git a/packages/ai-credits-widget/src/chainClient.ts b/packages/ai-credits-widget/src/chainClient.ts index cebf9b7d..4d716793 100644 --- a/packages/ai-credits-widget/src/chainClient.ts +++ b/packages/ai-credits-widget/src/chainClient.ts @@ -51,6 +51,7 @@ const DEPOSITS_ABI = parseAbi([ const FUNDING_VAULT_ABI = parseAbi([ 'function withdrawablePrincipal(address buyer) view returns (uint256)', + 'function usedNonces(address buyer) view returns (uint256)', ]) const GOODID_ABI = parseAbi([ @@ -81,6 +82,7 @@ export interface AiCreditsChainClient { operatorStatus?: BuyerOperatorStatus, ): Promise getWithdrawableUsd(buyer: string): Promise + getBuyerAuthNonce(buyer: string): Promise } export class ProductionAiCreditsChainClient implements AiCreditsChainClient { @@ -219,6 +221,20 @@ export class ProductionAiCreditsChainClient implements AiCreditsChainClient { return amount.toString() } + async getBuyerAuthNonce(buyer: string): Promise { + // Never fall back to a default: the nonce is signed over, so a wrong one produces a + // valid signature the vault will reject — or worse, replay-protect the wrong slot. + if (!this.fundingVaultAddress) { + throw new Error('Funding vault address is not configured') + } + return this.baseClient.readContract({ + address: this.fundingVaultAddress, + abi: FUNDING_VAULT_ABI, + functionName: 'usedNonces', + args: [normalizeAddress(buyer) as Address], + }) + } + private async readOperatorNonce(buyer: Address): Promise { return this.baseClient.readContract({ address: this.depositsAddress, diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx index 4609e73a..dd673f3c 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Button, ButtonText, @@ -9,12 +9,15 @@ import { Text, XStack, YStack, + Drawer, + ScrollArea, } from '@goodwidget/ui' import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState, } from '../../widgetRuntimeContract' import { SignerKeyPanel } from '../setup/SignerKeyPanel' +import { RevokeConsentStep } from './RevokeConsentStep' import { monospaceSingleLineStyle, compactButtonProps, truncateAddress } from '../shared/styles' import { useCopyFeedback } from '../shared/useCopyFeedback' @@ -77,13 +80,7 @@ function CopyableValue({ value, display }: { value: string; display?: string }) ) } -function SignerStatus({ - consented, - size = '$1', -}: { - consented: boolean - size?: '$1' | '$2' -}) { +function SignerStatus({ consented, size = '$1' }: { consented: boolean; size?: '$1' | '$2' }) { return ( { + if (!operatorConsented) setShowRevokeDrawer(false) + }, [operatorConsented]) + + const handleConfirmRevoke = () => { + setRevokeAttempted(true) + void Promise.resolve(actions.revokeOperatorConsent()) + } return ( @@ -256,6 +270,25 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { )} )} + {operatorConsented && buyerCanRevoke && ( + + )} )} @@ -327,6 +360,20 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { )} )} + + setShowRevokeDrawer(false)}> + + + setShowRevokeDrawer(false)} + /> + + + ) } diff --git a/packages/ai-credits-widget/src/components/manage/RevokeConsentStep.tsx b/packages/ai-credits-widget/src/components/manage/RevokeConsentStep.tsx new file mode 100644 index 00000000..0b39d9be --- /dev/null +++ b/packages/ai-credits-widget/src/components/manage/RevokeConsentStep.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { + Button, + ButtonText, + Icon, + PermissionList, + PermissionRow, + Spinner, + Text, + XStack, + YStack, + Heading, +} from '@goodwidget/ui' +import { truncateAddress, compactButtonProps, monospaceSingleLineStyle } from '../shared/styles' + +interface RevokeConsentStepProps { + buyerPubKey: string | null + operatorConsentPending?: boolean + /** Surfaced only after a confirm attempt, so a stale widget error stays out of this sheet. */ + error?: string | null + onConfirm: () => void + onCancel: () => void +} + +/** + * Drawer body for withdrawing the operator authorization — the counterpart to + * OperatorConsentStep, and deliberately shaped like it so the grant and the + * withdrawal read as two sides of the same permission. + */ +export function RevokeConsentStep({ + buyerPubKey, + operatorConsentPending = false, + error = null, + onConfirm, + onCancel, +}: RevokeConsentStepProps) { + return ( + + Unauthorize Wallet? + + This removes the operator's ability to act on your behalf. It is an on-chain change, + not a payment. + + + + + the operator from fulfilling purchases you initiate. + + + + + Your credits and your signer key stay where they are. You can authorize the wallet again at + any time. + + + {buyerPubKey && ( + + Buyer address:{' '} + + {truncateAddress(buyerPubKey)} + + + )} + + {error && ( + + + + {error} + + + )} + + + + + + + ) +} diff --git a/packages/ai-credits-widget/src/mocked/backendClient.ts b/packages/ai-credits-widget/src/mocked/backendClient.ts index 53da2997..1a7bd502 100644 --- a/packages/ai-credits-widget/src/mocked/backendClient.ts +++ b/packages/ai-credits-widget/src/mocked/backendClient.ts @@ -8,6 +8,7 @@ import type { DiscountConfig, GdCreditEntry, OperatorConsentResponse, + OperatorRevokeResponse, SettlementResult, TransactionsResponse, UserCreditProfile, @@ -16,7 +17,7 @@ import type { } from '../backendClient' import { DEFAULT_DISCOUNT_CONFIG, totalCreditUsdFromProfile } from '../backendClient' import type { AiCreditsBackendClient } from '../backendClient' -import { markMockOperatorConsent } from './chainClient' +import { clearMockOperatorConsent, markMockOperatorConsent } from './chainClient' const MOCK_DELAY_MS = 600 const DEFAULT_HISTORY_LIMIT = 20 @@ -224,4 +225,17 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { bridge: { enabled: true, txHash: '0xmock' }, } } + + async revokeOperatorConsent( + buyer: string, + {}: { nonce: string; signature: string }, + ): Promise { + await sleep(MOCK_DELAY_MS) + // Mirrors submitOperatorConsent: the chain mock is what waitForOperatorRevoke polls. + clearMockOperatorConsent(normalizeAddress(buyer)) + return { + buyer: normalizeAddress(buyer), + bridge: { enabled: true, txHash: '0xmock' }, + } + } } diff --git a/packages/ai-credits-widget/src/mocked/chainClient.ts b/packages/ai-credits-widget/src/mocked/chainClient.ts index 16d14361..1c84d799 100644 --- a/packages/ai-credits-widget/src/mocked/chainClient.ts +++ b/packages/ai-credits-widget/src/mocked/chainClient.ts @@ -6,13 +6,24 @@ import type { BuyerOperatorStatus, OperatorConsentPayloadResponse } from '../ope import type { AiCreditsQuote } from '../widgetRuntimeContract' const mockOperatorAcceptedBuyers = new Set() +// Revocation has to outrank the constructor's `operatorAccepted`, otherwise a mock seeded +// as consented could never report the withdrawal and waitForOperatorRevoke would spin. +const mockOperatorRevokedBuyers = new Set() function normalizeAddress(address: string): string { return address.toLowerCase() } export function markMockOperatorConsent(buyer: string): void { - mockOperatorAcceptedBuyers.add(normalizeAddress(buyer)) + const normalized = normalizeAddress(buyer) + mockOperatorRevokedBuyers.delete(normalized) + mockOperatorAcceptedBuyers.add(normalized) +} + +export function clearMockOperatorConsent(buyer: string): void { + const normalized = normalizeAddress(buyer) + mockOperatorAcceptedBuyers.delete(normalized) + mockOperatorRevokedBuyers.add(normalized) } export class MockAiCreditsChainClient implements AiCreditsChainClient { @@ -44,7 +55,9 @@ export class MockAiCreditsChainClient implements AiCreditsChainClient { const payer = normalizeAddress(ref.payer) const buyer = normalizeAddress(ref.buyer) const operatorAddress = '0x0000000000000000000000000000000000000004' - const operatorAccepted = this.operatorAccepted || mockOperatorAcceptedBuyers.has(buyer) + const operatorAccepted = + !mockOperatorRevokedBuyers.has(buyer) && + (this.operatorAccepted || mockOperatorAcceptedBuyers.has(buyer)) return { enabled: true, account: payer, @@ -85,4 +98,8 @@ export class MockAiCreditsChainClient implements AiCreditsChainClient { async getWithdrawableUsd(): Promise { return '0' } + + async getBuyerAuthNonce(): Promise { + return 0n + } } diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index a6329dc4..87159374 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -92,6 +92,7 @@ export interface AiCreditsWidgetAdapterActions { */ applyDeepLinkBuyer: (address: string, operatorSignature: string) => Promise signOperatorConsent: () => Promise + revokeOperatorConsent: () => Promise syncOperatorConsentFromChain: () => Promise buildQuote: (depositG: string, streamG: string) => Promise pay: (quote: AiCreditsQuote) => Promise diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts index 8fbd08c0..282d4a1c 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -14,14 +14,11 @@ const STORY_IDS = { '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--payment-confirmed&viewMode=story', creditsManagement: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--credits-management&viewMode=story', - historyTab: - '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--history-tab&viewMode=story', - setupTab: - '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--setup-tab&viewMode=story', + historyTab: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--history-tab&viewMode=story', + setupTab: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--setup-tab&viewMode=story', insufficientBalance: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--insufficient-g-balance&viewMode=story', - buyTabError: - '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--buy-tab-error&viewMode=story', + buyTabError: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--buy-tab-error&viewMode=story', paymentFailed: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--payment-failed&viewMode=story', backendUnavailable: @@ -147,13 +144,28 @@ test('AiCreditsWidget payment_confirmed', async ({ page }) => { test('AiCreditsWidget manage tab', async ({ page }) => { await gotoStory(page, STORY_IDS.creditsManagement) const root = page.getByTestId('AiCreditsWidget-manage-tab') - await expect(root).toBeVisible() + await expect(root).toBeVisible({ timeout: 10_000 }) await expect(root.getByText('Set Up', { exact: true })).toBeVisible() await expect(root.getByText('Buy Credits', { exact: true })).toBeVisible() await expect(root.getByText('Manage', { exact: true })).toBeVisible() await expect(root.getByText('History', { exact: true })).toBeVisible() await expect(page.getByText('110.00')).toBeVisible() await expect(page.getByText('Credit History')).not.toBeVisible() + // The Signer Key card is collapsed at rest; unauthorize lives inside it. + await root.getByTestId('signer-key-toggle').click() + await root.getByRole('button', { name: 'Unauthorize Wallet' }).click() + + // Like the Set Up authorization sheet, the Drawer renders through a Tamagui Sheet + // portal outside the widget root, so its content is queried at the page level. + const revokeSheetTitle = page.getByText('Unauthorize Wallet?', { exact: true }) + await expect(revokeSheetTitle).toBeVisible() + await expect( + page.getByText(/removes the operator's ability to act on your behalf/i), + ).toBeVisible() + await expect(page.getByText(/your bonus balance/i)).toBeVisible() + + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(revokeSheetTitle).not.toBeVisible() await page.screenshot({ path: 'tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png', fullPage: true, @@ -189,7 +201,9 @@ test('AiCreditsWidget buy tab with error', async ({ page }) => { const root = page.getByTestId('AiCreditsWidget-buy-tab-error') await expect(root).toBeVisible() await expect(root.getByText('Request Failed', { exact: true })).toBeVisible() - await expect(root.getByText('Network request failed. Please try again.', { exact: true }).first()).toBeVisible() + await expect( + root.getByText('Network request failed. Please try again.', { exact: true }).first(), + ).toBeVisible() await expect(root.getByRole('button', { name: 'Buy AI Credits' })).toBeVisible() await page.screenshot({ path: 'tests/widgets/ai-credits-widget/test-results/acw-15-buy-tab-error.png', @@ -400,9 +414,7 @@ test('AiCreditsWidget deep-link authorization pending: Authorize Wallet requires // The Drawer renders via a Tamagui Sheet portal outside the widget's root DOM // subtree, so its content must be queried at the page level, not scoped to `root`. - await expect( - page.getByText(/A one-time, on-chain approval — not a payment/i), - ).toBeVisible() + await expect(page.getByText(/A one-time, on-chain approval — not a payment/i)).toBeVisible() await expect(page.getByText(/you can revoke it at any time/i)).toBeVisible() await expect(page.getByText('Wallet authorized')).not.toBeVisible() @@ -448,7 +460,6 @@ test('AiCreditsWidget multi-buyer: signer key import is reachable', async ({ pag }) }) - // --------------------------------------------------------------------------- // Setup guidance card tests // ---------------------------------------------------------------------------