diff --git a/packages/core/README.md b/packages/core/README.md index 46ff408c2..e02b9390d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -637,6 +637,63 @@ setTimeout( ) ``` +**`preflightNotifications`** +Notify can be used to deliver standard notifications along with preflight information by passing a `PreflightNotificationsOptions` object to the `preflightNotifications` action. This will return a a promise that resolves to the transaction hash (if `sendTransaction` resolves the transaction hash and is successful), the internal notification id (if no `sendTransaction` function is provided) or return nothing if an error occurs or `sendTransaction` is not provided or doesn't resolve to a string. + +Preflight event types include + - `txRequest` : Alert user there is a transaction request awaiting confirmation by their wallet + - `txAwaitingApproval` : A previous transaction is awaiting confirmation + - `txConfirmReminder` : Reminder to confirm a transaction to continue - configurable with the `txApproveReminderTimeout` property; defaults to 15 seconds + - `nsfFail` : The user has insufficient funds for transaction (requires `gasPrice`, `estimateGas`, `balance`, `txDetails.value`) + - `txError` : General transaction error (requires `sendTransaction`) + - `txSendFail` : The user rejected the transaction (requires `sendTransaction`) + - `txUnderpriced` : The gas price for the transaction is too low (requires `sendTransaction`) + +```typescript +interface PreflightNotificationsOptions { + sendTransaction?: () => Promise + estimateGas?: () => Promise + gasPrice?: () => Promise + balance?: string | number + txDetails?: { + value: string | number + to?: string + from?: string + } + txApproveReminderTimeout?: number // defaults to 15 seconds if not specified +} +``` + +```typescript +const balanceValue = Object.values(balance)[0] +const ethersProvider = new ethers.providers.Web3Provider(provider, 'any') + +const signer = ethersProvider.getSigner() +const txDetails = { + to: toAddress, + value: 100000000000000 +} + +const sendTransaction = () => { + return signer.sendTransaction(txDetails).then(tx => tx.hash) +} + +const gasPrice = () => + ethersProvider.getGasPrice().then(res => res.toString()) + +const estimateGas = () => { + return ethersProvider.estimateGas(txDetails).then(res => res.toString()) +} +const transactionHash = await onboard.state.actions.preflightNotifications({ + sendTransaction, + gasPrice, + estimateGas, + balance: balanceValue, + txDetails: txDetails +}) +console.log(transactionHash) +``` + **`updateAccountCenter`** If you need to update your Account Center configuration after initialization, you can call the `updateAccountCenter` function with the new configuration diff --git a/packages/core/package.json b/packages/core/package.json index ad55ee580..dd501cb46 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@web3-onboard/core", - "version": "2.4.0-alpha.9", - "description": "Web3-Onboard makes it simple to connect Ethereum hardware and software wallets to your dapp. Features standardised spec compliant web3 providers for all supported wallets, framework agnostic modern javascript UI with code splitting, CSS customization, multi-chain and multi-account support, reactive wallet state subscriptions and real-time transaction state change notifications.", + "version": "2.4.0-alpha.10", + "description": "Web3-Onboard makes it simple to connect Ethereum hardware and software wallets to your dapp. Features standardized spec compliant web3 providers for all supported wallets, framework agnostic modern javascript UI with code splitting, CSS customization, multi-chain and multi-account support, reactive wallet state subscriptions and real-time transaction state change notifications.", "keywords": [ "Ethereum", "Web3", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad692c4bd..902705f60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ import { } from './store/actions' import updateBalances from './update-balances' +import { preflightNotifications } from './preflight-notifications' const API = { connectWallet, @@ -39,6 +40,7 @@ const API = { setLocale, updateNotify, customNotification, + preflightNotifications, updateBalances, updateAccountCenter, setPrimaryWallet diff --git a/packages/core/src/notify.ts b/packages/core/src/notify.ts index 31557c1b2..6e085efe0 100644 --- a/packages/core/src/notify.ts +++ b/packages/core/src/notify.ts @@ -139,8 +139,6 @@ export function eventToType(eventCode: string | undefined): NotificationType { case 'txRepeat': case 'txAwaitingApproval': case 'txConfirmReminder': - case 'txStallPending': - case 'txStallConfirmed': case 'txStuck': return 'hint' case 'txError': diff --git a/packages/core/src/preflight-notifications.ts b/packages/core/src/preflight-notifications.ts new file mode 100644 index 000000000..2e3ef041f --- /dev/null +++ b/packages/core/src/preflight-notifications.ts @@ -0,0 +1,233 @@ +import BigNumber from 'bignumber.js' +import { nanoid } from 'nanoid' +import defaultCopy from './i18n/en.json' +import type { Network } from 'bnc-sdk' + +import type { Notification, PreflightNotificationsOptions } from './types' +import { addNotification, removeNotification } from './store/actions' +import { state } from './store' +import { eventToType } from './notify' +import { networkToChainId } from './utils' +import { validatePreflightNotifications } from './validation' + +let notificationsArr: Notification[] +state.select('notifications').subscribe(notifications => { + notificationsArr = notifications +}) + +export async function preflightNotifications( + options: PreflightNotificationsOptions +): Promise { + + + const invalid = validatePreflightNotifications(options) + + if (invalid) { + throw invalid + } + + const { + sendTransaction, + estimateGas, + gasPrice, + balance, + txDetails, + txApproveReminderTimeout + } = options + + // Check for reminder timeout and confirm its greater than 3 seconds + const reminderTimeout: number = + txApproveReminderTimeout && txApproveReminderTimeout > 3000 + ? txApproveReminderTimeout + : 15000 + + // if `balance` or `estimateGas` or `gasPrice` is not provided, + // then sufficient funds check is disabled + // if `txDetails` is not provided, + // then duplicate transaction check is disabled + // if dev doesn't want notify to initiate the transaction + // and `sendTransaction` is not provided, then transaction + // rejected notification is disabled + // to disable hints for `txAwaitingApproval`, `txConfirmReminder` + // or any other notification, then return false from listener functions + + const [gas, price] = await gasEstimates(estimateGas, gasPrice) + const id = createId(nanoid()) + const value = new BigNumber((txDetails && txDetails.value) || 0) + + // check sufficient balance if required parameters are available + if (balance && gas && price) { + const transactionCost = gas.times(price).plus(value) + + // if transaction cost is greater than the current balance + if (transactionCost.gt(new BigNumber(balance))) { + const eventCode = 'nsfFail' + + const newNotification = buildNotification(eventCode, id) + addNotification(newNotification) + } + } + + // check previous transactions awaiting approval + const txRequested = notificationsArr.find(tx => tx.eventCode === 'txRequest') + + if (txRequested) { + const eventCode = 'txAwaitingApproval' + + const newNotification = buildNotification(eventCode, txRequested.id) + addNotification(newNotification) + } + + // confirm reminder timeout defaults to 20 seconds + setTimeout(() => { + const awaitingApproval = notificationsArr.find( + tx => tx.id === id && tx.eventCode === 'txRequest' + ) + + if (awaitingApproval) { + const eventCode = 'txConfirmReminder' + + const newNotification = buildNotification(eventCode, awaitingApproval.id) + addNotification(newNotification) + } + }, reminderTimeout) + + const eventCode = 'txRequest' + const newNotification = buildNotification(eventCode, id) + addNotification(newNotification) + + // if not provided with sendTransaction function, + // resolve with transaction hash(or void) so dev can initiate transaction + if (!sendTransaction) { + return id + } + // get result and handle errors + let hash + try { + hash = await sendTransaction() + } catch (error) { + type CatchError = { + message: string + stack: string + } + const { eventCode, errorMsg } = extractMessageFromError(error as CatchError) + + const newNotification = buildNotification(eventCode, id) + addNotification(newNotification) + console.error(errorMsg) + return + } + + // Remove preflight notification if a resolves to hash + // and let the SDK take over + removeNotification(id) + if (hash) { + return hash + } + return +} + +const buildNotification = (eventCode: string, id: string): Notification => { + return { + eventCode, + type: eventToType(eventCode), + id, + key: createKey(id, eventCode), + message: createMessageText(eventCode), + startTime: Date.now(), + network: Object.keys(networkToChainId).find( + key => networkToChainId[key] === state.get().chains[0].id + ) as Network, + autoDismiss: 0 + } +} + +const createKey = (id: string, eventCode: string): string => { + return `${id}-${eventCode}` +} + +const createId = (id: string): string => { + return `${id}-preflight` +} + +const createMessageText = (eventCode: string): string => { + const notificationDefaultMessages = defaultCopy.notify + + const notificationMessageType = notificationDefaultMessages.transaction + + return notificationDefaultMessages.transaction[ + eventCode as keyof typeof notificationMessageType + ] +} + +export function extractMessageFromError(error: { + message: string + stack: string +}): { eventCode: string; errorMsg: string } { + if (!error.stack || !error.message) { + return { + eventCode: 'txError', + errorMsg: 'An unknown error occured' + } + } + + const message = error.stack || error.message + + if (message.includes('User denied transaction signature')) { + return { + eventCode: 'txSendFail', + errorMsg: 'User denied transaction signature' + } + } + + if (message.includes('transaction underpriced')) { + return { + eventCode: 'txUnderpriced', + errorMsg: 'Transaction is under priced' + } + } + + return { + eventCode: 'txError', + errorMsg: message + } +} + +const gasEstimates = async ( + gasFunc: () => Promise, + gasPriceFunc: () => Promise +) => { + if (!gasFunc || !gasPriceFunc) { + return Promise.resolve([]) + } + + const gasProm = gasFunc() + if (!gasProm.then) { + throw new Error('The `estimateGas` function must return a Promise') + } + + const gasPriceProm = gasPriceFunc() + if (!gasPriceProm.then) { + throw new Error('The `gasPrice` function must return a Promise') + } + + return Promise.all([gasProm, gasPriceProm]) + .then(([gasResult, gasPriceResult]) => { + if (typeof gasResult !== 'string') { + throw new Error( + `The Promise returned from calling 'estimateGas' must resolve with a value of type 'string'. Received a value of: ${gasResult} with a type: ${typeof gasResult}` + ) + } + + if (typeof gasPriceResult !== 'string') { + throw new Error( + `The Promise returned from calling 'gasPrice' must resolve with a value of type 'string'. Received a value of: ${gasPriceResult} with a type: ${typeof gasPriceResult}` + ) + } + + return [new BigNumber(gasResult), new BigNumber(gasPriceResult)] + }) + .catch(error => { + throw new Error(`There was an error getting gas estimates: ${error}`) + }) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7f31b9f82..313519286 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -138,9 +138,9 @@ export type NotificationPosition = CommonPositions export type AccountCenter = { enabled: boolean position?: AccountCenterPosition - containerElement?: string expanded?: boolean minimal?: boolean + containerElement: string } export type AccountCenterOptions = { @@ -208,6 +208,19 @@ export interface UpdateNotification { } } +export interface PreflightNotificationsOptions { + sendTransaction?: () => Promise + estimateGas?: () => Promise + gasPrice?: () => Promise + balance?: string | number + txDetails?: { + value: string | number + to?: string + from?: string + } + txApproveReminderTimeout?: number +} + // ==== ACTIONS ==== // export type Action = | AddChainsAction diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 2a208f1f8..71f5b1b92 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -18,7 +18,8 @@ import type { Notification, CustomNotification, CustomNotificationUpdate, - Notify + Notify, + PreflightNotificationsOptions } from './types' const chainId = Joi.string().pattern(/^0x[0-9a-fA-F]+$/) @@ -213,6 +214,25 @@ const customNotificationUpdate = Joi.object({ link: Joi.string() }) +const preflightNotifications = Joi.object({ + sendTransaction: Joi.function(), + estimateGas: Joi.function(), + gasPrice: Joi.function(), + balance: Joi.alternatives( + Joi.string(), + Joi.number() + ), + txDetails: Joi.object({ + value: Joi.alternatives( + Joi.string(), + Joi.number() + ), + to: Joi.string(), + from: Joi.string() + }), + txApproveReminderTimeout: Joi.number() +}) + const customNotification = Joi.object({ key: Joi.string(), type: Joi.string().allow('pending', 'error', 'success', 'hint'), @@ -325,6 +345,11 @@ export function validateTransactionHandlerReturn( export function validateNotification(data: Notification): ValidateReturn { return validate(notification, data) } +export function validatePreflightNotifications( + data: PreflightNotificationsOptions +): ValidateReturn { + return validate(preflightNotifications, data) +} export function validateCustomNotificationUpdate( data: CustomNotificationUpdate diff --git a/packages/core/src/views/notify/NotificationContent.svelte b/packages/core/src/views/notify/NotificationContent.svelte index 7eb65a688..851dd6bfc 100644 --- a/packages/core/src/views/notify/NotificationContent.svelte +++ b/packages/core/src/views/notify/NotificationContent.svelte @@ -74,7 +74,7 @@ {notification.message} - {#if notification.id && !notification.id.includes('customNotification')} + {#if notification.id && (!notification.id.includes('customNotification') && !notification.id.includes('preflight') )} {#if notification.link} - {#if !notification.id.includes('customNotification')} + {#if !notification.id.includes('customNotification') && !notification.id.includes('preflight')}
{ + const balanceValue = Object.values(balance)[0] + const ethersProvider = new ethers.providers.Web3Provider(provider, 'any') + + const signer = ethersProvider.getSigner() + const txDetails = { + to: toAddress, + value: 100000000000000 + } + + const sendTransaction = () => { + return signer.sendTransaction(txDetails).then(tx => tx.hash) + } + + const gasPrice = () => + ethersProvider.getGasPrice().then(res => res.toString()) + + const estimateGas = () => { + return ethersProvider.estimateGas(txDetails).then(res => res.toString()) + } + + const transactionHash = await onboard.state.actions.preflightNotifications({ + sendTransaction, + gasPrice, + estimateGas, + balance: balanceValue, + txDetails: txDetails + }) + + console.log(transactionHash) + } + const signMessage = async (provider, address) => { const ethersProvider = new ethers.providers.Web3Provider(provider, 'any') @@ -477,6 +509,17 @@ Send Transaction
+
+ + +