diff --git a/packages/mask/src/components/shared/WalletStatusBox/TransactionList.tsx b/packages/mask/src/components/shared/WalletStatusBox/TransactionList.tsx index 8e21593ef2bb..9e030ec6a7bb 100644 --- a/packages/mask/src/components/shared/WalletStatusBox/TransactionList.tsx +++ b/packages/mask/src/components/shared/WalletStatusBox/TransactionList.tsx @@ -109,12 +109,12 @@ const Transaction: FC = ({ chainId, transaction: tx, onClear = const [txStatus, setTxStatus] = useState(tx.status) useEffect(() => { - const removeListener = TransactionWatcher?.emitter.on('progress', (id, status, transaction) => { + const off = TransactionWatcher?.emitter.on('progress', (id, status, transaction) => { setTxStatus(status) }) return () => { - removeListener?.() + off?.() } }, [tx.id, TransactionWatcher]) diff --git a/packages/plugin-infra/src/web3-state/TransactionWatcher.ts b/packages/plugin-infra/src/web3-state/TransactionWatcher.ts index a122592e0f0b..e58a25ecef44 100644 --- a/packages/plugin-infra/src/web3-state/TransactionWatcher.ts +++ b/packages/plugin-infra/src/web3-state/TransactionWatcher.ts @@ -1,13 +1,17 @@ +import { omit } from 'lodash-unified' +import type { Subscription } from 'use-subscription' import { Emitter } from '@servie/events' +import { getSubscriptionCurrentValue, StorageItem } from '@masknet/shared-base' import { TransactionChecker, TransactionStatusType, WatchEvents, TransactionWatcherState as Web3TransactionWatcherState, + RecentTransaction, } from '@masknet/web3-shared-base' import type { Plugin } from '../types' -interface StorageItem { +interface TransactionWatcherItem { at: number id: string chainId: ChainId @@ -15,77 +19,109 @@ interface StorageItem { transaction: Transaction } -class Storage { +type TransactionWatcher = Record< + // @ts-ignore + ChainId, + Record< + // transaction id + string, + TransactionWatcherItem + > +> + +class Watcher { static MAX_ITEM_SIZE = 40 - private map = new Map>>() + private timer: NodeJS.Timeout | null = null + + constructor( + protected storage: StorageItem>, + protected checkers: Array>, + protected options: { + checkDelay: number + getTransactionCreator: (transaction: Transaction) => string + onNotify: (id: string, status: TransactionStatusType, transaction: Transaction) => void + }, + ) {} private getStorage(chainId: ChainId) { - if (!this.map.has(chainId)) this.map.set(chainId, new Map()) - return this.map.get(chainId)! + return this.storage.value[chainId] } - public hasItem(chainId: ChainId, id: string) { - return this.getStorage(chainId).has(id) + private async setStorage(chainId: ChainId, id: string, item: TransactionWatcherItem) { + await this.storage.setValue({ + ...this.storage.value, + // @ts-ignore + [chainId]: { + ...this.storage.value[chainId], + [item.id]: item, + }, + }) } - public getItem(chainId: ChainId, id: string) { - return this.getStorage(chainId).get(id) + private async deleteStorage(chainId: ChainId, id: string) { + await this.storage.setValue({ + ...this.storage.value, + // @ts-ignore + [chainId]: omit(this.storage.value[chainId], [id]), + }) } - public setItem(chainId: ChainId, id: string, transaction: StorageItem) { - this.getStorage(chainId).set(id, transaction) + private async setTransaction( + chainId: ChainId, + id: string, + transaction: TransactionWatcherItem, + ) { + await this.setStorage(chainId, id, transaction) } - public removeItem(chainId: ChainId, id: string) { - this.getStorage(chainId).delete(id) + private async removeTransaction(chainId: ChainId, id: string) { + await this.deleteStorage(chainId, id) } - public getItems(chainId: ChainId) { - const map = this.getStorage(chainId) - return map ? [...map.entries()].sort(([, a], [, z]) => z.at - a.at) : [] + private getAllTransactions(chainId: ChainId) { + const storage = this.getStorage(chainId) + return storage ? [...Object.entries(storage)].sort(([, a], [, z]) => z.at - a.at) : [] } - public getWatched(chainId: ChainId) { - return this.getItems(chainId).slice(0, Storage.MAX_ITEM_SIZE) + private getWatched(chainId: ChainId) { + return this.getAllTransactions(chainId).slice(0, Watcher.MAX_ITEM_SIZE) } - public getUnwatched(chainId: ChainId) { - return this.getItems(chainId).slice(Storage.MAX_ITEM_SIZE) + private getUnwatched(chainId: ChainId) { + return this.getAllTransactions(chainId).slice(Watcher.MAX_ITEM_SIZE) } -} - -class Watcher { - static LATEST_TRANSACTION_SIZE = 5 - - private timer: NodeJS.Timeout | null = null - private storage = new Storage() - - constructor( - protected checkers: Array>, - protected options: { - delay: number - onNotify: (id: string, status: TransactionStatusType, transaction: Transaction) => void - }, - ) {} private async check(chainId: ChainId) { // stop any pending task this.stopCheck() // unwatch legacy transactions - this.storage.getUnwatched(chainId).forEach(([id]) => this.unwatchTransaction(chainId, id)) + for (const [id] of this.getUnwatched(chainId)) { + await this.unwatchTransaction(chainId, id) + } // check if all transactions were sealed - const watchedTransactions = this.storage - .getWatched(chainId) - .filter(([, x]) => x.status !== TransactionStatusType.NOT_DEPEND) + const watchedTransactions = this.getWatched(chainId).filter( + ([, x]) => x.status === TransactionStatusType.NOT_DEPEND, + ) if (!watchedTransactions.length) return for (const [id, { transaction }] of watchedTransactions) { for (const checker of this.checkers) { - const status = await checker.checkStatus(chainId, id) - if (status !== TransactionStatusType.NOT_DEPEND) this.options.onNotify(id, status, transaction) + try { + const status = await checker.checkStatus( + id, + chainId, + this.options.getTransactionCreator(transaction), + ) + if (status !== TransactionStatusType.NOT_DEPEND) { + this.removeTransaction(chainId, id) + this.options.onNotify(id, status, transaction) + } + } catch (error) { + console.warn('Failed to check transaction status.') + } } } @@ -93,71 +129,126 @@ class Watcher { this.startCheck(chainId) } - private startCheck(chainId: ChainId) { + public startCheck(chainId: ChainId) { this.stopCheck() if (this.timer === null) { - this.timer = setTimeout(this.check.bind(this, chainId), this.options.delay) + this.timer = setTimeout(this.check.bind(this, chainId), this.options.checkDelay) } } - private stopCheck() { + public stopCheck() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } - public watchTransaction(chainId: ChainId, id: string, transaction: Transaction) { - if (!this.storage.hasItem(chainId, id)) { - this.storage.setItem(chainId, id, { - at: Date.now(), - id, - chainId, - status: TransactionStatusType.NOT_DEPEND, - transaction, - }) - } + public async watchTransaction(chainId: ChainId, id: string, transaction: Transaction) { + await this.setTransaction(chainId, id, { + at: Date.now(), + id, + chainId, + status: TransactionStatusType.NOT_DEPEND, + transaction, + }) this.startCheck(chainId) } - public unwatchTransaction(chainId: ChainId, id: string) { - this.storage.removeItem(chainId, id) + public async unwatchTransaction(chainId: ChainId, id: string) { + await this.removeTransaction(chainId, id) } } export class TransactionWatcherState implements Web3TransactionWatcherState { + protected storage: StorageItem> = null! private watchers: Map> = new Map() emitter: Emitter> = new Emitter() constructor( protected context: Plugin.Shared.SharedContext, + protected chainIds: ChainId[], protected checkers: Array>, + protected subscriptions: { + chainId?: Subscription + transactions?: Subscription>> + }, protected options: { /** Default block delay in seconds */ defaultBlockDelay: number + /** Get the author address */ + getTransactionCreator: (transaction: Transaction) => string }, - ) {} + ) { + const defaultValue = Object.fromEntries(chainIds.map((x) => [x, {}])) as TransactionWatcher< + ChainId, + Transaction + > + const { storage } = this.context.createKVStorage('memory', {}).createSubScope('TransactionWatcher', { + value: defaultValue, + }) + + this.storage = storage.value + + const resume = async () => { + const chainId = this.subscriptions.chainId?.getCurrentValue() + if (chainId) this.resumeWatcher(chainId) + + const transactions = this.subscriptions.transactions?.getCurrentValue() ?? [] + for (const transaction of transactions) { + for (const [id, tx] of Object.entries(transaction.candidates)) { + if (transaction.status === TransactionStatusType.NOT_DEPEND) + await this.watchTransaction(transaction.chainId, id, tx) + } + } + } + + // resume watcher if chain id changed + if (this.subscriptions.chainId) { + this.subscriptions.chainId.subscribe(() => resume()) + + getSubscriptionCurrentValue(() => { + return this.subscriptions.chainId + }).then(() => { + resume() + }) + } + + // add external transactions at startup + if (this.subscriptions.transactions) { + getSubscriptionCurrentValue(() => { + return this.subscriptions.transactions + }).then(() => { + resume() + }) + } + } private getWatcher(chainId: ChainId) { if (!this.watchers.has(chainId)) this.watchers.set( chainId, - new Watcher(this.checkers, { - delay: this.options.defaultBlockDelay * 1000, + new Watcher(this.storage, this.checkers, { + checkDelay: this.options.defaultBlockDelay * 1000, + getTransactionCreator: this.options.getTransactionCreator, onNotify: this.notifyTransaction.bind(this), }), ) return this.watchers.get(chainId)! } - watchTransaction(chainId: ChainId, id: string, transaction: Transaction) { - this.getWatcher(chainId).watchTransaction(chainId, id, transaction) + private resumeWatcher(chainId: ChainId) { + const watcher = this.getWatcher(chainId) + watcher.startCheck(chainId) + } + + async watchTransaction(chainId: ChainId, id: string, transaction: Transaction) { + await this.getWatcher(chainId).watchTransaction(chainId, id, transaction) this.emitter.emit('progress', id, TransactionStatusType.NOT_DEPEND, transaction) } - unwatchTransaction(chainId: ChainId, id: string) { - this.getWatcher(chainId).unwatchTransaction(chainId, id) + async unwatchTransaction(chainId: ChainId, id: string) { + await this.getWatcher(chainId).unwatchTransaction(chainId, id) } notifyTransaction(id: string, status: TransactionStatusType, transaction: Transaction) { diff --git a/packages/plugin-infra/src/web3/useClearTransactionsCallback.ts b/packages/plugin-infra/src/web3/useClearTransactionsCallback.ts index e02220dbfaad..7261c8880d10 100644 --- a/packages/plugin-infra/src/web3/useClearTransactionsCallback.ts +++ b/packages/plugin-infra/src/web3/useClearTransactionsCallback.ts @@ -3,14 +3,25 @@ import type { NetworkPluginID } from '@masknet/web3-shared-base' import { useAccount } from './useAccount' import { useChainId } from './useChainId' import { useWeb3State } from './useWeb3State' +import { getSubscriptionCurrentValue } from '@masknet/shared-base' export function useClearTransactionsCallback(pluginID?: T) { const account = useAccount(pluginID) const chainId = useChainId(pluginID) - const { Transaction } = useWeb3State(pluginID) + const { Transaction, TransactionWatcher } = useWeb3State(pluginID) return useCallback(async () => { if (!account) return + + try { + const transactions = await getSubscriptionCurrentValue(() => Transaction?.transactions) + for (const transaction of transactions?.flatMap((x) => Object.keys(x.candidates)) ?? []) { + await TransactionWatcher?.unwatchTransaction(chainId, transaction) + } + } catch { + console.warn('Failed to unwatch transaction.') + } + return Transaction?.clearTransactions?.(chainId, account) }, [chainId, account, Transaction]) } diff --git a/packages/plugin-infra/src/web3/useRemoveTransaction.ts b/packages/plugin-infra/src/web3/useRemoveTransaction.ts index f6b1de675720..49326449409d 100644 --- a/packages/plugin-infra/src/web3/useRemoveTransaction.ts +++ b/packages/plugin-infra/src/web3/useRemoveTransaction.ts @@ -7,12 +7,13 @@ import { useWeb3State } from './useWeb3State' export function useRemoveTransactionCallback(pluginID?: T) { const account = useAccount(pluginID) const chainId = useChainId(pluginID) - const { Transaction } = useWeb3State(pluginID) + const { Transaction, TransactionWatcher } = useWeb3State(pluginID) return useCallback( async (id: string) => { if (!account) return - return Transaction?.removeTransaction?.(chainId, account, id) + await TransactionWatcher?.unwatchTransaction(chainId, id) + await Transaction?.removeTransaction?.(chainId, account, id) }, [chainId, account, Transaction], ) diff --git a/packages/plugins/EVM/src/state/Connection/middleware/Transaction.ts b/packages/plugins/EVM/src/state/Connection/middleware/Transaction.ts index f9281ef74731..2819826ff527 100644 --- a/packages/plugins/EVM/src/state/Connection/middleware/Transaction.ts +++ b/packages/plugins/EVM/src/state/Connection/middleware/Transaction.ts @@ -22,7 +22,7 @@ export class RecentTransaction implements Middleware { context.result, context.config, ) - TransactionWatcher?.watchTransaction(context.chainId, context.result, context.config) + await TransactionWatcher?.watchTransaction(context.chainId, context.result, context.config) break case EthereumMethodType.ETH_GET_TRANSACTION_RECEIPT: if (isSquashed) return diff --git a/packages/plugins/EVM/src/state/Connection/providers/MaskWallet.ts b/packages/plugins/EVM/src/state/Connection/providers/MaskWallet.ts index b8ef87153bd1..f94dd62015e4 100644 --- a/packages/plugins/EVM/src/state/Connection/providers/MaskWallet.ts +++ b/packages/plugins/EVM/src/state/Connection/providers/MaskWallet.ts @@ -9,8 +9,6 @@ import { first } from 'lodash-unified' import type { ProviderOptions } from '@masknet/web3-shared-base' export class MaskWalletProvider extends BaseProvider implements EVM_Provider { - private id = 0 - constructor() { super() Web3StateSettings.readyPromise.then(this.addSharedContextListeners.bind(this)) @@ -55,10 +53,8 @@ export class MaskWalletProvider extends BaseProvider implements EVM_Provider { requestArguments: RequestArguments, options?: ProviderOptions, ): Promise { - this.id += 1 - const response = await SharedContextSettings.value.send( - createPayload(this.id, requestArguments.method, requestArguments.params), + createPayload(0, requestArguments.method, requestArguments.params), { chainId: SharedContextSettings.value.chainId.getCurrentValue(), popupsWindow: getSiteType() === ExtensionSite.Dashboard || isEnhanceableSiteType(), diff --git a/packages/plugins/EVM/src/state/TransactionWatcher.ts b/packages/plugins/EVM/src/state/TransactionWatcher.ts index a7bcb4d47e6d..5466bddad638 100644 --- a/packages/plugins/EVM/src/state/TransactionWatcher.ts +++ b/packages/plugins/EVM/src/state/TransactionWatcher.ts @@ -1,12 +1,28 @@ +import type { Subscription } from 'use-subscription' +import { getEnumAsArray } from '@dimensiondev/kit' import type { Plugin } from '@masknet/plugin-infra' import { TransactionWatcherState } from '@masknet/plugin-infra/web3' -import type { ChainId, Transaction } from '@masknet/web3-shared-evm' +import { ChainId, Transaction } from '@masknet/web3-shared-evm' +import type { RecentTransaction } from '@masknet/web3-shared-base' import { TransactionCheckers } from './TransactionWatcher/checker' export class TransactionWatcher extends TransactionWatcherState { - constructor(context: Plugin.Shared.SharedContext) { - super(context, TransactionCheckers, { - defaultBlockDelay: 15, - }) + constructor( + context: Plugin.Shared.SharedContext, + subscriptions: { + chainId?: Subscription + transactions?: Subscription>> + }, + ) { + super( + context, + getEnumAsArray(ChainId).map((x) => x.value), + TransactionCheckers, + subscriptions, + { + defaultBlockDelay: 15, + getTransactionCreator: (tx) => (tx.from as string | undefined) ?? '', + }, + ) } } diff --git a/packages/plugins/EVM/src/state/TransactionWatcher/checkers/AccountChecker.ts b/packages/plugins/EVM/src/state/TransactionWatcher/checkers/AccountChecker.ts index bda76e0751cb..2d30b8f1d59c 100644 --- a/packages/plugins/EVM/src/state/TransactionWatcher/checkers/AccountChecker.ts +++ b/packages/plugins/EVM/src/state/TransactionWatcher/checkers/AccountChecker.ts @@ -1,8 +1,29 @@ +import { first } from 'lodash-unified' +import { Explorer } from '@masknet/web3-providers' import type { TransactionChecker, TransactionStatusType } from '@masknet/web3-shared-base' -import type { ChainId } from '@masknet/web3-shared-evm' +import { ChainId, getExplorerConstants } from '@masknet/web3-shared-evm' +/** + * Fetch latest tx list of the the given account. It creates a signature for each transaction. + * Treat two transactions the same with the identical transaction hash or signature. + */ export class AccountChecker implements TransactionChecker { - checkStatus(chainId: ChainId, id: string): Promise { - throw new Error('Method not implemented.') + static CHECK_LATEST_TRANSACTION_SIZE = 5 + + private async fetchLatestTransactions(chainId: ChainId, account: string) { + const { API_KEYS = [], EXPLORER_API = '' } = getExplorerConstants(chainId) + return Explorer.getLatestTransactions(account, EXPLORER_API, { + offset: AccountChecker.CHECK_LATEST_TRANSACTION_SIZE, + apikey: first(API_KEYS), + }) + } + + async checkStatus(id: string, chainId: ChainId, account: string): Promise { + throw new Error('Method not implemented') + // const latestTransactions = await this.fetchLatestTransactions(chainId, account) + // const tx = latestTransactions.find((x) => x.hash === id) + // if (!tx) return TransactionStatusType.NOT_DEPEND + // // '1' for successful transactions and '0' for failed transactions. + // return tx.status === '1' ? TransactionStatusType.SUCCEED : TransactionStatusType.FAILED } } diff --git a/packages/plugins/EVM/src/state/TransactionWatcher/checkers/ReceiptChecker.ts b/packages/plugins/EVM/src/state/TransactionWatcher/checkers/ReceiptChecker.ts index 8d13da095e47..e3fe518a7195 100644 --- a/packages/plugins/EVM/src/state/TransactionWatcher/checkers/ReceiptChecker.ts +++ b/packages/plugins/EVM/src/state/TransactionWatcher/checkers/ReceiptChecker.ts @@ -1,8 +1,14 @@ import type { TransactionChecker, TransactionStatusType } from '@masknet/web3-shared-base' import type { ChainId } from '@masknet/web3-shared-evm' +import { Web3StateSettings } from '../../../settings' +import { getReceiptStatus } from '../../Connection/utils' export class ReceiptChecker implements TransactionChecker { - checkStatus(chainId: ChainId, id: string): Promise { - throw new Error('Method not implemented.') + async checkStatus(id: string, chainId: ChainId): Promise { + const connection = await Web3StateSettings.value.Connection?.getConnection?.({ + chainId, + }) + const receipt = await connection?.getTransactionReceipt(id) + return getReceiptStatus(receipt ?? null) } } diff --git a/packages/plugins/EVM/src/state/index.ts b/packages/plugins/EVM/src/state/index.ts index 606c9c0ffd55..e40147fa2eee 100644 --- a/packages/plugins/EVM/src/state/index.ts +++ b/packages/plugins/EVM/src/state/index.ts @@ -20,6 +20,10 @@ import { BlockNumberNotifier } from './BlockNumberNotifier' export function createWeb3State(context: Plugin.Shared.SharedContext): EVM_Web3State { const Provider_ = new Provider(context) const Settings_ = new Settings(context) + const Transaction_ = new Transaction(context, { + chainId: Provider_.chainId, + account: Provider_.account, + }) return { Settings: Settings_, @@ -44,12 +48,12 @@ export function createWeb3State(context: Plugin.Shared.SharedContext): EVM_Web3S Token: new Token(context, { account: Provider_.account, }), - Transaction: new Transaction(context, { + Transaction: Transaction_, + TransactionFormatter: new TransactionFormatter(context), + TransactionWatcher: new TransactionWatcher(context, { chainId: Provider_.chainId, - account: Provider_.account, + transactions: Transaction_.transactions, }), - TransactionFormatter: new TransactionFormatter(context), - TransactionWatcher: new TransactionWatcher(context), Connection: new Connection(context, { chainId: Provider_.chainId, account: Provider_.account, diff --git a/packages/shared-base/src/utils/subscription.ts b/packages/shared-base/src/utils/subscription.ts index b393ab942212..e5a2cf188d83 100644 --- a/packages/shared-base/src/utils/subscription.ts +++ b/packages/shared-base/src/utils/subscription.ts @@ -1,7 +1,37 @@ import { noop } from 'lodash-unified' -import type { ValueRef } from '@dimensiondev/holoflows-kit' import type { Subscription } from 'use-subscription' import { None, Option, Some } from 'ts-results' +import type { ValueRef } from '@dimensiondev/holoflows-kit' + +export async function getSubscriptionCurrentValue( + getSubscription: () => Subscription | undefined, + retries = 3, +): Promise { + const getValue = () => { + return getSubscription()?.getCurrentValue() + } + + const createReader = async () => { + try { + return getValue() + } catch (error: unknown) { + if (!(error instanceof Promise)) return + await error + return getValue() + } + } + + const createReaders = Array.from<() => Promise>({ length: retries }).fill(() => createReader()) + + for (const createReader of createReaders) { + try { + return await createReader() + } catch { + continue + } + } + return +} export function createConstantSubscription(value: T): Subscription { return { diff --git a/packages/web3-providers/src/MagicEden/index.ts b/packages/web3-providers/src/MagicEden/index.ts index 43109fee3790..f2bd5712c568 100644 --- a/packages/web3-providers/src/MagicEden/index.ts +++ b/packages/web3-providers/src/MagicEden/index.ts @@ -190,7 +190,7 @@ export class MagicEdenAPI implements NonFungibleTokenAPI.Provider ({ id: x.bid, chainId: ChainId.Mainnet, - asset_permalink: link, + assetPermalink: link, hash: x.bid, quantity: '1', createdAt: x.timestamp * 1000, @@ -263,7 +263,7 @@ export class MagicEdenAPI implements NonFungibleTokenAPI.Provider ({ id: x.order_hash, chainId, - asset_permalink: asset.opensea_link, + assetPermalink: asset.opensea_link, hash: x.order_hash, quantity: x.quantity, createdAt: x.created_time ? getUnixTime(new Date(x.created_time)) : undefined, @@ -243,7 +243,7 @@ function createAssetOrder(chainId: ChainId, order: OpenSeaAssetOrder): NonFungib return { id: order.order_hash, chainId, - asset_permalink: order.asset.opensea_link, + assetPermalink: order.asset.opensea_link, hash: order.order_hash, quantity: order.quantity, side: order.side, diff --git a/packages/web3-providers/src/rarible/index.ts b/packages/web3-providers/src/rarible/index.ts index 65adb44c9356..332db2d7a145 100644 --- a/packages/web3-providers/src/rarible/index.ts +++ b/packages/web3-providers/src/rarible/index.ts @@ -211,7 +211,7 @@ export class RaribleAPI implements NonFungibleTokenAPI.Provider { /** chain Id */ chainId: ChainId /** permalink of asset */ - asset_permalink: string + assetPermalink: string /** token amount */ quantity: string /** transaction hash */ @@ -556,7 +556,7 @@ export interface ProviderOptions { } export interface TransactionChecker { - checkStatus(chainId: ChainId, id: string): Promise + checkStatus(id: string, chainId: ChainId, account: string): Promise } export interface ConnectionOptions { @@ -927,9 +927,9 @@ export interface TransactionWatcherState { emitter: Emitter> /** Add a transaction into the watch list. */ - watchTransaction: (chainId: ChainId, id: string, transaction: Transaction) => void + watchTransaction: (chainId: ChainId, id: string, transaction: Transaction) => Promise /** Remove a transaction from the watch list. */ - unwatchTransaction: (chainId: ChainId, id: string) => void + unwatchTransaction: (chainId: ChainId, id: string) => Promise /** Update transaction status */ notifyTransaction: (id: string, status: TransactionStatusType, transaction: Transaction) => void }