diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 75d7556c..25781a11 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -13,10 +13,11 @@ crossorigin iframes data-testid -# scripts +# scripts and 3rd party terms typecheck prettiercache corepack +linkcode # packages and 3rd party tools/libraries awilix diff --git a/src/background/services/background.ts b/src/background/services/background.ts index b564a841..92068163 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -32,6 +32,7 @@ export class Background { this.bindOnInstalled() this.bindMessageHandler() this.bindPermissionsHandler() + this.bindStateHandler() this.bindTabHandlers() this.bindWindowHandlers() } @@ -154,18 +155,30 @@ export class Background { bindPermissionsHandler() { this.browser.permissions.onAdded.addListener(this.checkPermissions) this.browser.permissions.onRemoved.addListener(this.checkPermissions) - this.events.on('storage.host_permissions_update', async ({ status }) => { - this.logger.info('permission changed', { status }) + } + + bindStateHandler() { + this.events.on('storage.state_update', async ({ state, prevState }) => { + this.logger.info('state changed', { state, prevState }) // TODO: change icon here in future }) } bindOnInstalled() { this.browser.runtime.onInstalled.addListener(async (details) => { - this.logger.info(await this.storage.get()) + const data = await this.storage.get() + this.logger.info(data) if (details.reason === 'install') { await this.storage.populate() await this.openPaymentsService.generateKeys() + } else if (details.reason === 'update') { + const migrated = await this.storage.migrate() + if (migrated) { + const prevVersion = data.version ?? 1 + this.logger.info( + `Migrated from ${prevVersion} to ${migrated.version}` + ) + } } await this.checkPermissions() }) @@ -174,8 +187,9 @@ export class Background { checkPermissions = async () => { try { this.logger.debug('checking hosts permission') - const status = await this.browser.permissions.contains(PERMISSION_HOSTS) - this.storage.setHostPermissionStatus(status) + const hasPermissions = + await this.browser.permissions.contains(PERMISSION_HOSTS) + this.storage.setState({ missing_host_permissions: !hasPermissions }) } catch (error) { this.logger.error(error) } diff --git a/src/background/services/events.ts b/src/background/services/events.ts index 26849837..6e9a3d1c 100644 --- a/src/background/services/events.ts +++ b/src/background/services/events.ts @@ -1,8 +1,12 @@ import { EventEmitter } from 'events' +import type { Storage } from '@/shared/types' interface BackgroundEvents { 'storage.rate_of_pay_update': { rate: string } - 'storage.host_permissions_update': { status: boolean } + 'storage.state_update': { + state: Storage['state'] + prevState: Storage['state'] + } } export class EventsService extends EventEmitter { diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index f8493ff7..1cc9f50d 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -252,15 +252,14 @@ export class MonetizationService { const storedData = await this.storage.get([ 'enabled', 'connected', - 'hasHostPermissions', - 'amount', + 'state', 'rateOfPay', 'minRateOfPay', 'maxRateOfPay', 'walletAddress', 'publicKey' ]) - + const balance = await this.storage.getBalance() const tab = await getCurrentActiveTab(this.browser) let url @@ -279,6 +278,7 @@ export class MonetizationService { return { ...storedData, + balance: balance.total.toString(), url, isSiteMonetized } diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 36182272..f01a0804 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -1,5 +1,5 @@ // cSpell:ignore keyid -import { AccessToken, WalletAmount } from 'shared/types' +import type { AccessToken, GrantDetails, WalletAmount } from 'shared/types' import { type AuthenticatedClient, createAuthenticatedClient @@ -92,6 +92,7 @@ export class OpenPaymentsService { client?: AuthenticatedClient private token: AccessToken + private grant: GrantDetails | null constructor( private browser: Browser, @@ -102,15 +103,22 @@ export class OpenPaymentsService { } private async initialize() { - const { token, connected, walletAddress } = await this.storage.get([ - 'connected', - 'walletAddress', - 'token' - ]) - - if (connected === true && walletAddress && token) { + const { connected, walletAddress, oneTimeGrant, recurringGrant } = + await this.storage.get([ + 'connected', + 'walletAddress', + 'oneTimeGrant', + 'recurringGrant' + ]) + + if ( + connected === true && + walletAddress && + (recurringGrant || oneTimeGrant) + ) { + this.grant = recurringGrant || oneTimeGrant! + this.token = this.grant.accessToken await this.initClient(walletAddress.id) - this.token = token } } @@ -330,25 +338,41 @@ export class OpenPaymentsService { throw new Error('Expected finalized grant. Received non-finalized grant.') } - const token = { - value: continuation.access_token.value, - manage: continuation.access_token.manage + const grantDetails: GrantDetails = { + type: recurring ? 'recurring' : 'one-time', + amount: transformedAmount as Required, + accessToken: { + value: continuation.access_token.value, + manageUrl: continuation.access_token.manage + }, + continue: { + accessToken: continuation.continue.access_token.value, + url: continuation.continue.uri + } } - await this.storage.set({ + const data = { walletAddress, rateOfPay, minRateOfPay, maxRateOfPay, - amount: transformedAmount, - token, - grant: { - accessToken: continuation.continue.access_token.value, - continueUri: continuation.continue.uri - }, connected: true - }) - this.token = token + } + if (grantDetails.type === 'recurring') { + await this.storage.set({ + ...data, + recurringGrant: grantDetails, + recurringGrantSpentAmount: '0' + }) + } else { + await this.storage.set({ + ...data, + oneTimeGrant: grantDetails, + oneTimeGrantSpentAmount: '0' + }) + } + this.grant = grantDetails + this.token = this.grant.accessToken } private async createOutgoingPaymentGrant({ @@ -459,18 +483,20 @@ export class OpenPaymentsService { } async disconnectWallet() { - const { grant } = await this.storage.get(['grant']) + const { recurringGrant, oneTimeGrant } = await this.storage.get([ + 'recurringGrant', + 'oneTimeGrant' + ]) + // TODO: When both types of grant can co-exist, make sure to revoke them + // correctly (either specific grant or all grants). See + // https://github.com/interledger/web-monetization-extension/pull/379#discussion_r1660447849 + const grant = recurringGrant || oneTimeGrant if (grant) { - await this.client!.grant.cancel({ - url: grant.continueUri, - accessToken: grant.accessToken - }) + await this.client!.grant.cancel(grant.continue) await this.storage.clear() - this.token = { - value: '', - manage: '' - } + this.grant = null + this.token = { value: '', manageUrl: '' } } } @@ -514,18 +540,24 @@ export class OpenPaymentsService { } async rotateToken() { + if (!this.grant) { + throw new Error('No grant to rotate token for') + } const rotate = this.deduplicator.dedupe(this.client!.token.rotate) const newToken = await rotate({ - url: this.token.manage, + url: this.token.manageUrl, accessToken: this.token.value }) - const token = { + const accessToken: AccessToken = { value: newToken.access_token.value, - manage: newToken.access_token.manage + manageUrl: newToken.access_token.manage } - await this.storage.set({ - token - }) - this.token = token + if (this.grant.type === 'recurring') { + this.storage.set({ recurringGrant: { ...this.grant, accessToken } }) + } else { + this.storage.set({ oneTimeGrant: { ...this.grant, accessToken } }) + } + this.grant.accessToken = accessToken + this.token = accessToken } } diff --git a/src/background/services/storage.ts b/src/background/services/storage.ts index 67dcb139..05f2cfcd 100644 --- a/src/background/services/storage.ts +++ b/src/background/services/storage.ts @@ -1,16 +1,32 @@ -import type { Storage, StorageKey } from '@/shared/types' +import type { + AmountValue, + GrantDetails, + Storage, + StorageKey, + WalletAmount +} from '@/shared/types' import { type Browser } from 'webextension-polyfill' import { EventsService } from './events' +import { computeBalance } from '../utils' const defaultStorage = { + /** + * For migrations, increase this version and add a migration script in + * {@linkcode MIGRATIONS}. New additions to structure that can be dynamically + * set don't need migrations (e.g. we check if value is null etc.) but other + * structural changes would need migrations for keeping compatibility with + * existing installations. + */ + version: 2, + state: null, connected: false, enabled: true, - hasHostPermissions: true, exceptionList: {}, walletAddress: null, - amount: null, - token: null, - grant: null, + recurringGrant: null, + recurringGrantSpentAmount: '0', + oneTimeGrant: null, + oneTimeGrantSpentAmount: '0', rateOfPay: null, minRateOfPay: null, maxRateOfPay: null @@ -47,6 +63,32 @@ export class StorageService { } } + /** + * Migrate storage to given target version. + */ + async migrate(targetVersion: Storage['version'] = defaultStorage.version) { + const storage = this.browser.storage.local + + let { version = 1 } = await this.get(['version']) + if (version === targetVersion) { + return null + } + + let data = await storage.get() + while (version < targetVersion) { + ++version + const migrate = MIGRATIONS[version] + if (!migrate) { + throw new Error(`No migration available to reach version "${version}"`) + } + const [newData, deleteKeys = []] = migrate(data) + data = { ...newData, version } + await storage.set(data) + await storage.remove(deleteKeys) + } + return data as Storage + } + async getWMState(): Promise { const { enabled } = await this.get(['enabled']) @@ -69,11 +111,56 @@ export class StorageService { return false } - async setHostPermissionStatus(status: boolean): Promise { - const { hasHostPermissions } = await this.get(['hasHostPermissions']) - if (hasHostPermissions !== status) { - await this.set({ hasHostPermissions: status }) - this.events.emit('storage.host_permissions_update', { status }) + // TODO: ensure correct transitions between states, while also considering + // race conditions. + async setState( + state: null | Record, boolean> + ): Promise { + const { state: prevState } = await this.get(['state']) + + let newState: Storage['state'] = null + if (state !== null) { + if (typeof state.missing_host_permissions === 'boolean') { + if (state.missing_host_permissions) { + newState = 'missing_host_permissions' + } + } + } + + if (prevState === newState) { + return false + } + + await this.set({ state: newState }) + this.events.emit('storage.state_update', { + state: newState, + prevState: prevState + }) + return true + } + + async getBalance(): Promise< + Record<'recurring' | 'oneTime' | 'total', AmountValue> + > { + const data = await this.get([ + 'recurringGrant', + 'recurringGrantSpentAmount', + 'oneTimeGrant', + 'oneTimeGrantSpentAmount' + ]) + const balanceRecurring = computeBalance( + data.recurringGrant, + data.recurringGrantSpentAmount + ) + const balanceOneTime = computeBalance( + data.oneTimeGrant, + data.oneTimeGrantSpentAmount + ) + const balance = balanceRecurring + balanceOneTime + return { + total: balance.toString(), + recurring: balanceRecurring.toString(), + oneTime: balanceOneTime.toString() } } @@ -82,3 +169,60 @@ export class StorageService { this.events.emit('storage.rate_of_pay_update', { rate }) } } + +/** + * @param existingData Existing data from previous version. + */ +type Migration = ( + existingData: Record +) => [data: Record, deleteKeys?: string[]] + +// There was never a migration to reach 1. +// +// In future, we may remove older version migrations as unsupported. That would +// require user to reinstall and setup extension from scratch. +const MIGRATIONS: Record = { + 2: (data) => { + const deleteKeys = ['amount', 'token', 'grant', 'hasHostPermissions'] + + data.recurringGrant = null + data.recurringGrantSpentAmount = '0' + data.oneTimeGrant = null + data.oneTimeGrantSpentAmount = '0' + data.state = null + + if (data.amount?.value && data.token && data.grant) { + const type = data.amount.interval ? 'recurring' : 'one-time' + + const grantDetails: GrantDetails = { + type, + amount: { + value: data.amount.value as string, + ...(type === 'recurring' + ? { interval: data.amount.interval as string } + : {}) + } as Required, + accessToken: { + value: data.token.value as string, + manageUrl: data.token.manage as string + }, + continue: { + url: data.grant.continueUri as string, + accessToken: data.grant.accessToken as string + } + } + + if (type === 'recurring') { + data.recurringGrant = grantDetails + } else { + data.oneTimeGrant = grantDetails + } + } + + if (data.hasHostPermissions === false) { + data.state = 'missing_host_permissions' satisfies Storage['state'] + } + + return [data, deleteKeys] + } +} diff --git a/src/background/utils.ts b/src/background/utils.ts index 3f67934f..d57370fd 100644 --- a/src/background/utils.ts +++ b/src/background/utils.ts @@ -1,4 +1,4 @@ -import { WalletAmount } from '@/shared/types' +import { AmountValue, GrantDetails, WalletAmount } from '@/shared/types' import { type Browser, Runtime } from 'webextension-polyfill' import { DEFAULT_SCALE, EXCHANGE_RATES_URL } from './config' import { notNullOrUndef } from '@/shared/helpers' @@ -89,3 +89,12 @@ export const getSender = (sender: Runtime.MessageSender) => { export const computeRate = (rate: string, sessionsCount: number) => (+rate / sessionsCount).toString() + +export function computeBalance( + grant?: GrantDetails | null, + grantSpentAmount?: AmountValue | null +) { + if (!grant?.amount) return 0n + const total = BigInt(grant.amount.value) + return grantSpentAmount ? total - BigInt(grantSpentAmount) : total +} diff --git a/src/popup/components/ProtectedRoute.tsx b/src/popup/components/ProtectedRoute.tsx index af919c52..697a47ad 100644 --- a/src/popup/components/ProtectedRoute.tsx +++ b/src/popup/components/ProtectedRoute.tsx @@ -6,7 +6,7 @@ import { ROUTES_PATH } from '../Popup' export const ProtectedRoute = () => { const { state } = React.useContext(PopupStateContext) - if (!state.hasHostPermissions) { + if (state.state === 'missing_host_permissions') { return } if (state.connected === false) { diff --git a/src/popup/components/WalletInformation.tsx b/src/popup/components/WalletInformation.tsx index 49b9dca4..1c723086 100644 --- a/src/popup/components/WalletInformation.tsx +++ b/src/popup/components/WalletInformation.tsx @@ -46,7 +46,7 @@ export const WalletInformation = ({ info }: WalletInformationProps) => { readOnly={true} value={formatNumber( +transformBalance( - info.amount?.value ?? '0', + info.balance ?? '0', info.walletAddress?.assetScale ?? 2 ), info.walletAddress?.assetScale ?? 2 diff --git a/src/popup/lib/utils.ts b/src/popup/lib/utils.ts index 1d1ff62f..df62db64 100644 --- a/src/popup/lib/utils.ts +++ b/src/popup/lib/utils.ts @@ -11,7 +11,10 @@ export const getCurrencySymbol = (assetCode: string): string => { .trim() } -export const transformBalance = (amount: string, scale: number): string => { +export const transformBalance = ( + amount: string | bigint, + scale: number +): string => { const value = BigInt(amount) const divisor = BigInt(10 ** scale) diff --git a/src/shared/types.ts b/src/shared/types.ts index e8290a38..713c8046 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,8 @@ import { WalletAddress } from '@interledger/open-payments/dist/types' +/** Bigint amount, before transformation with assetScale */ +export type AmountValue = string + /** Wallet amount */ export interface WalletAmount { value: string @@ -9,32 +12,48 @@ export interface WalletAmount { /** Amount interface - used in the `exceptionList` */ export interface Amount { - value: string + value: AmountValue interval: number } -export interface WebsiteData { - url: string - amount: Amount -} - export interface AccessToken { - value: string - manage: string + value: AmountValue + manageUrl: string } -export interface GrantDetails { - accessToken: string - continueUri: string +interface GrantDetailsBase { + type: string + accessToken: AccessToken + continue: { url: string; accessToken: string } } +export interface OneTimeGrant extends GrantDetailsBase { + type: 'one-time' + amount: Omit +} +export interface RecurringGrant extends GrantDetailsBase { + type: 'recurring' + amount: Required +} +export type GrantDetails = OneTimeGrant | RecurringGrant export interface Storage { + /** + * Storage structure version. Used in migrations. Numbers are sequential. + * Inspired by database upgrades in IndexedDB API. + */ + version: number + /** If web monetization is enabled */ enabled: boolean /** If a wallet is connected or not */ connected: boolean - /** Whether extension can inject scripts, and fetch resources from any host */ - hasHostPermissions: boolean + /** Extension state */ + state: + | never // just added for code formatting + /** Normal */ + | null + /** Extension can't inject scripts and fetch resources from all hosts */ + | 'missing_host_permissions' rateOfPay?: string | undefined | null minRateOfPay?: string | undefined | null @@ -42,12 +61,12 @@ export interface Storage { /** User wallet address information */ walletAddress?: WalletAddress | undefined | null - /** Overall amount */ - amount?: WalletAmount | undefined | null - /** Access token for outgoing payments */ - token?: AccessToken | undefined | null - /** Grant details - continue access token & uri for canceling the grant */ - grant?: GrantDetails | undefined | null + + recurringGrant?: RecurringGrant | undefined | null + recurringGrantSpentAmount?: AmountValue | undefined | null + oneTimeGrant?: OneTimeGrant | undefined | null + oneTimeGrantSpentAmount?: AmountValue | undefined | null + /** Exception list with websites and each specific amount */ exceptionList: { [website: string]: Amount @@ -61,8 +80,14 @@ export type StorageKey = keyof Storage export type PopupStore = Omit< Storage, - 'privateKey' | 'keyId' | 'exceptionList' | 'token' | 'grant' + | 'version' + | 'privateKey' + | 'keyId' + | 'exceptionList' + | 'recurringGrant' + | 'oneTimeGrant' > & { + balance: AmountValue isSiteMonetized: boolean url: string | undefined }