diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index 807b7806..35990089 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -68,6 +68,7 @@ function createAdapterFactory( importBuyerFromPrivateKey: async () => {}, applyDeepLinkBuyer: async () => {}, signOperatorConsent: async () => {}, + revokeOperatorConsent: async () => {}, syncOperatorConsentFromChain: async () => {}, buildQuote: async (depositG, streamG) => ({ depositAmountG: depositG, diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index 4d2bb055..2985108a 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -14,6 +14,7 @@ import { YStack, Spinner, createToast, + GoodWidgetDialog, updateToast, } from '@goodwidget/ui' import { useAiCreditsAdapter } from './adapter' @@ -293,6 +294,23 @@ function ManagePanel({ ) } +function WidgetDialog() { + return ( + ( + + )} + renderReject={(onPress, label) => ( + + )} + /> + ) +} + function AiCreditsInner({ environment, backendUrl, @@ -460,6 +478,7 @@ export function AiCreditsWidget({ onPaySuccess={onPaySuccess} onPayError={onPayError} /> + diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 1bbb7af0..a5ffe213 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -13,7 +13,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, @@ -162,10 +167,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()) @@ -324,11 +326,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) } @@ -369,9 +367,7 @@ export function useAiCreditsAdapter({ const { address, chainId, isConnected, provider, connect } = 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 @@ -478,13 +474,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) @@ -559,8 +554,7 @@ export function useAiCreditsAdapter({ ...patch, ...accountPatch, ...buyerFields, - operatorConsented: - accountPatch.operatorConsented ?? buyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? buyerFields.operatorConsented, ...(account ? {} : { activeTab: 'buy' as const }), }, true, @@ -648,10 +642,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, { @@ -748,8 +739,7 @@ export function useAiCreditsAdapter({ mergeStatePreservingNonBuyTab(prev, { ...accountPatch, ...nextBuyerFields, - operatorConsented: - accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? nextBuyerFields.operatorConsented, error: null, }), ) @@ -772,9 +762,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 } }) @@ -786,7 +774,11 @@ export function useAiCreditsAdapter({ async (rawPrivateKey: string) => { if (!address) { setState((prev) => - withDerivedStatus(prev, { error: 'Connect your wallet before importing a buyer key' }, true), + withDerivedStatus( + prev, + { error: 'Connect your wallet before importing a buyer key' }, + true, + ), ) return } @@ -809,10 +801,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, { @@ -836,17 +825,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, @@ -861,7 +842,11 @@ export function useAiCreditsAdapter({ } } 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, + ), ) } }, @@ -955,11 +940,7 @@ export function useAiCreditsAdapter({ const currentState = state if (!currentState.address || !currentState.buyerPubKey) { setState((prev) => - withDerivedStatus( - prev, - { error: 'Select a buyer before signing operator consent' }, - true, - ), + withDerivedStatus(prev, { error: 'Select a buyer before signing operator consent' }, true), ) return } @@ -1093,6 +1074,101 @@ 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, + }) + + 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) @@ -1194,10 +1270,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) } @@ -1335,12 +1416,10 @@ export function useAiCreditsAdapter({ { ...accountPatch, ...buyerFields, - operatorConsented: - accountPatch.operatorConsented ?? buyerFields.operatorConsented, + operatorConsented: accountPatch.operatorConsented ?? buyerFields.operatorConsented, activeTab: prev.activeTab, error: null, - depositBonusPercent: - discountConfig?.depositBonusPercent ?? prev.depositBonusPercent, + depositBonusPercent: discountConfig?.depositBonusPercent ?? prev.depositBonusPercent, streamBonusPercent: discountConfig?.streamBonusPercent ?? prev.streamBonusPercent, }, true, @@ -1444,15 +1523,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) => ({ @@ -1461,7 +1545,7 @@ export function useAiCreditsAdapter({ })) } }, - [state, backendClient, fundingVaultAddress], + [state, backendClient, chainClient, fundingVaultAddress], ) const handleWithdrawCredits = useCallback( @@ -1502,20 +1586,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 })) @@ -1527,7 +1611,7 @@ export function useAiCreditsAdapter({ })) } }, - [state, backendClient, fundingVaultAddress, handleRefresh], + [state, backendClient, chainClient, fundingVaultAddress, handleRefresh], ) const handleRetry = useCallback(async () => { @@ -1564,8 +1648,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, @@ -1619,6 +1702,7 @@ export function useAiCreditsAdapter({ importBuyerFromPrivateKey: handleImportBuyerFromPrivateKey, applyDeepLinkBuyer: handleApplyDeepLinkBuyer, signOperatorConsent: handleSignOperatorConsent, + revokeOperatorConsent: handleRevokeOperatorConsent, syncOperatorConsentFromChain: handleSyncOperatorConsentFromChain, buildQuote: handleBuildQuote, pay: handlePay, @@ -1639,6 +1723,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 6abbd1af..859feb06 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, @@ -227,6 +234,10 @@ export interface AiCreditsBackendClient { buyer: string, body: OperatorConsentRequest, ): Promise + revokeOperatorConsent( + buyer: string, + body: OperatorRevokeRequest, + ): Promise } const BPS_PER_PERCENT = 100 @@ -427,6 +438,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 { @@ -473,6 +503,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 8d639777..7c57214c 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([ @@ -80,6 +81,7 @@ export interface AiCreditsChainClient { operatorStatus?: BuyerOperatorStatus, ): Promise getWithdrawableUsd(buyer: string): Promise + getBuyerAuthNonce(buyer: string): Promise } export class ProductionAiCreditsChainClient implements AiCreditsChainClient { @@ -212,6 +214,16 @@ export class ProductionAiCreditsChainClient implements AiCreditsChainClient { return amount.toString() } + async getBuyerAuthNonce(buyer: string): Promise { + if (!this.fundingVaultAddress) return 0n + 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 7e9d0dcc..d77f1aef 100644 --- a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx +++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx @@ -1,5 +1,18 @@ import React, { useState } from 'react' -import { Button, ButtonText, Card, Heading, Icon, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' +import { + Button, + ButtonText, + Card, + Heading, + Icon, + Input, + Spinner, + Text, + XStack, + YStack, + closeDialog, + createDialog, +} from '@goodwidget/ui' import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract' import { AddressView } from '../shared/AddressView' import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles' @@ -22,6 +35,7 @@ interface BuyerOperatorCardProps { | 'selectBuyer' | 'importBuyerFromPrivateKey' | 'signOperatorConsent' + | 'revokeOperatorConsent' > } @@ -163,6 +177,25 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { const buyerCanSign = Boolean(buyerPrvKey || operatorSignature) + const handleOpenRevokeDialog = () => { + createDialog({ + title: 'Revoke Operator?', + body: + "Revoking removes the operator's ability to act on your behalf. Any bonus balance will be deducted, and any active stream bonuses will stop.", + acceptLabel: 'Revoke Operator', + rejectLabel: 'Cancel', + showClose: true, + onAccept: async () => { + try { + await actions.revokeOperatorConsent() + } finally { + closeDialog() + } + }, + onReject: () => {}, + }) + } + return ( Buyer & Operator @@ -211,6 +244,20 @@ export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) { + {operatorConsented && ( + + )} + {showImport ? ( { + await sleep(MOCK_DELAY_MS) + 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..2593a74f 100644 --- a/packages/ai-credits-widget/src/mocked/chainClient.ts +++ b/packages/ai-credits-widget/src/mocked/chainClient.ts @@ -85,4 +85,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 2a699e04..51600292 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -83,6 +83,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 @@ -149,4 +150,3 @@ 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 d56a984c..487c55e8 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -108,12 +108,23 @@ test('AiCreditsWidget payment_confirmed', async ({ page }) => { test('AiCreditsWidget manage tab', async ({ page }) => { await gotoStory(page, STORY_IDS.creditsManagement) - await expect(page.getByTestId('AiCreditsWidget-manage-tab')).toBeVisible() + await expect(page.getByTestId('AiCreditsWidget-manage-tab')).toBeVisible({ timeout: 10_000 }) await expect(page.getByText('Buy Credits')).toBeVisible() await expect(page.getByText('Manage')).toBeVisible() await expect(page.getByText('History')).toBeVisible() await expect(page.getByText('110.00')).toBeVisible() await expect(page.getByText('Credit History')).not.toBeVisible() + const revokeButton = page.getByRole('button', { name: 'Revoke Operator' }) + await expect(revokeButton).toBeVisible() + await revokeButton.click() + await expect(page.getByText('Revoke Operator?', { exact: true })).toBeVisible() + await expect( + page.getByText( + "Revoking removes the operator's ability to act on your behalf. Any bonus balance will be deducted, and any active stream bonuses will stop.", + ), + ).toBeVisible() + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByText('Revoke Operator?', { exact: true })).toHaveCount(0) await page.screenshot({ path: 'tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png', fullPage: true, @@ -345,4 +356,3 @@ test('AiCreditsWidget multi-buyer: import buyer key link is visible', async ({ p fullPage: true, }) }) - diff --git a/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png b/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png index daebcef7..f04b0c78 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png and b/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png differ