diff --git a/.circleci/config.yml b/.circleci/config.yml index 80002c98e..41e06eecc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -394,7 +394,7 @@ jobs: - image: cimg/node:16.13.1 working_directory: ~/web3-onboard-monorepo/packages/web3auth steps: - - node-build-steps + - node-staging-build-steps build-staging-dcent: docker: - image: cimg/node:16.13.1 diff --git a/.github/workflows/issue-to-notion.yml b/.github/workflows/issue-to-notion.yml index 2ba48c1f6..21405fdfe 100644 --- a/.github/workflows/issue-to-notion.yml +++ b/.github/workflows/issue-to-notion.yml @@ -15,13 +15,13 @@ jobs: -H 'Authorization: Bearer '"$NOTION_TOKEN"'' \ -H "Content-Type: application/json" \ -H "Notion-Version: 2022-02-22" \ - --data '{"parent":{"type":"database_id","database_id":"'"$DATABASE_ID"'"},"icon":{"type":"emoji","emoji":"🐛"},"properties":{"Name":{"type":"title","title":[{"type":"text","text":{"content":"'"$TITLE"'"}}]},"Assigned":{"people":['${people:0:-1}']},"Status":{"select":{"name":"'"$STATUS"'"}},"Flag":{"multi_select":[{"name":"'"$FLAG"'"}]},"Type":{"multi_select":[{"name":"bug"}]},"Project":{"multi_select":[{"name":"'"$PROJECT_NAME"'"}]}},"children":[{"object":"block","type":"bookmark","bookmark":{"url":"'"$ISSUE_URL"'"}}]}' + --data '{"parent":{"type":"database_id","database_id":"'"$DATABASE_ID"'"},"icon":{"type":"emoji","emoji":"🐛"},"properties":{"Name":{"type":"title","title":[{"type":"text","text":{"content":"'"$(echo $TITLE | tr '"' "'")"'"}}]},"Assigned":{"people":['${people:0:-1}']},"Status":{"select":{"name":"'"$STATUS"'"}},"Flag":{"multi_select":[{"name":"'"$FLAG"'"}]},"Type":{"multi_select":[{"name":"bug"}]},"Project":{"multi_select":[{"name":"'"$PROJECT_NAME"'"}]}},"children":[{"object":"block","type":"bookmark","bookmark":{"url":"'"$ISSUE_URL"'"}}]}' env: NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} STATE: ${{ github.event.issue.state }} ISSUE_URL: ${{ github.event.issue.html_url }} TITLE: ${{ github.event.issue.title }} - FLAG: Next Sprint + FLAG: Github STATUS: Backlog # Product Work Board DATABASE_ID: 29876f9a9b864ca39a984f42e17fd345 diff --git a/README.md b/README.md index 2b4d4d45e..92fe4a95c 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ - **Minimal Dependencies**: All wallet dependencies are included in separate packages, so you only include the ones you want to use in your app. - **Multiple Wallets and Accounts Connection**: Allow your users to connect multiple wallets and multiple accounts within each wallet at the same time to your app. - **Multiple Chain Support**: Allow users to switch between chains/networks with ease. +- **Account Center**: A persistent interface to manage wallet connections and networks, with a minimal version for mobile +- **Notify**: Real-time transaction notifications for the connected wallet addresses for all transaction states - **Wallet Provider Standardization**: All wallet modules expose a provider that is patched to be compliant with the [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193), [EIP-1102](https://eips.ethereum.org/EIPS/eip-1102), [EIP-3085](https://eips.ethereum.org/EIPS/eip-3085) and [EIP-3326](https://ethereum-magicians.org/t/eip-3326-wallet-switchethereumchain/5471) specifications. -- **Dynamic Imports**: Supporting multiple wallets in your app requires a lot of dependencies. Onboard dynamically imports a wallet and it's dependencies only when the user selects it, so that minimal bandwidth is used. +- **Dynamic Imports**: Supporting multiple wallets in your app requires a lot of dependencies. Onboard dynamically imports a wallet and its dependencies only when the user selects it, so that minimal bandwidth is used. ## Quickstart diff --git a/package.json b/package.json index 341a17545..824fad638 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web3-onboard-monorepo", - "version": "2.0.2", + "version": "2.1.0", "private": true, "workspaces": [ "./packages/*" diff --git a/packages/coinbase/package.json b/packages/coinbase/package.json index d27f7df96..2ea4ac5a4 100644 --- a/packages/coinbase/package.json +++ b/packages/coinbase/package.json @@ -1,6 +1,6 @@ { "name": "@web3-onboard/coinbase", - "version": "2.0.4", + "version": "2.0.5", "description": "Coinbase Wallet module for web3-onboard", "module": "dist/index.js", "browser": "dist/index.js", @@ -21,6 +21,6 @@ }, "dependencies": { "@coinbase/wallet-sdk": "^3.0.5", - "@web3-onboard/common": "2.1.1" + "@web3-onboard/common": "2.1.2" } } diff --git a/packages/common/package.json b/packages/common/package.json index 350ff6335..b0eca131e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@web3-onboard/common", - "version": "2.1.1", + "version": "2.1.2", "scripts": { "build": "rollup -c", "dev": "rollup -c -w", diff --git a/packages/common/src/elements/AddressTable.svelte b/packages/common/src/elements/AddressTable.svelte index 2e3ce51ba..704098e1a 100644 --- a/packages/common/src/elements/AddressTable.svelte +++ b/packages/common/src/elements/AddressTable.svelte @@ -55,7 +55,7 @@ } tbody tr:hover { - background-color: var( + background: var( --account-select-primary-100, var(--onboard-primary-100, var(--primary-100)) ); @@ -70,7 +70,7 @@ .selected-row, .selected-row:hover { - background-color: var( + background: var( --account-select-primary-500, var(--onboard-primary-500, var(--primary-500)) ); diff --git a/packages/common/src/elements/TableHeader.svelte b/packages/common/src/elements/TableHeader.svelte index ec9fc6ac8..7abd0e9c8 100644 --- a/packages/common/src/elements/TableHeader.svelte +++ b/packages/common/src/elements/TableHeader.svelte @@ -71,7 +71,7 @@ } input:disabled { - background-color: var( + background: var( --account-select-gray-100, var(--onboard-gray-100, var(--gray-100)) ); @@ -80,7 +80,7 @@ input[type='checkbox'] { -webkit-appearance: none; width: auto; - background-color: var( + background: var( --account-select-white, var(--onboard-white, var(--white)) ); @@ -105,7 +105,7 @@ } input[type='checkbox']:checked { - background-color: var( + background: var( --account-select-primary-500, var(--onboard-primary-500, var(--primary-500)) ); diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index b93475114..ed14165f2 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -430,6 +430,8 @@ export interface Chain { color?: string icon?: string // svg string providerConnectionInfo?: ConnectionInfo + publicRpcUrl?: string + blockExplorerUrl?: string } export type TokenSymbol = string // eg ETH diff --git a/packages/common/src/views/AccountSelect.svelte b/packages/common/src/views/AccountSelect.svelte index ec8fdc8d1..2cbf071bb 100644 --- a/packages/common/src/views/AccountSelect.svelte +++ b/packages/common/src/views/AccountSelect.svelte @@ -243,7 +243,7 @@ } select:disabled { - background-color: var( + background: var( --account-select-gray-100, var(--onboard-gray-100, var(--gray-100)) ); @@ -397,7 +397,7 @@ right: 0.2rem; width: 2.5rem; height: 2.5rem; - background-color: var( + background: var( --account-select-white, var(--onboard-white, var(--white)) ); diff --git a/packages/core/README.md b/packages/core/README.md index a6f43fcc9..303f0822c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,7 +10,7 @@ Install the core module: If you would like to support all wallets, then you can install all of the wallet modules: -`npm i @web3-onboard/injected-wallets @web3-onboard/coinbase @web3-onboard/ledger @web3-onboard/trezor @web3-onboard/keepkey @web3-onboard/walletconnect @web3-onboard/torus @web3-onboard/portis @web3-onboard/mew @web3-onboard/gnosis @web3-onboard/magic @web3-onboard/fortmatic` +`npm i @web3-onboard/injected-wallets @web3-onboard/coinbase @web3-onboard/ledger @web3-onboard/trezor @web3-onboard/keepkey @web3-onboard/walletconnect @web3-onboard/web3auth @web3-onboard/torus @web3-onboard/portis @web3-onboard/mew @web3-onboard/gnosis @web3-onboard/magic @web3-onboard/fortmatic @web3-onboard/dcent` Note: @@ -29,6 +29,8 @@ type InitOptions { appMetadata?: AppMetadata i18n?: i18nOptions accountCenter?: AccountCenterOptions + apiKey?: string + notify?: Partial } ``` @@ -49,6 +51,8 @@ type Chain = { token: TokenSymbol // the native token symbol, eg ETH, BNB, MATIC color?: string // the color used to represent the chain and will be used as a background for the icon icon?: string // the icon to represent the chain + publicRpcUrl?: string // an optional public RPC used when adding a new chain config to the wallet + blockExplorerUrl?: string // also used when adding a new config to the wallet } ``` @@ -114,6 +118,99 @@ type AccountCenterPosition = | 'topLeft' ``` +**`notify`** +Notify provides by default transaction notifications for all connected wallets on the current blockchain. When switching chains the previous chain listeners remain active for 60 seconds to allow capture and report of an remaining transactions that may be in flight. +By default transaction notifications are captured if a DAppID is provided in the Onboard config along with the Account Center being enabled. +An object that defines whether transaction notifications will display (defaults to true if an API key is provided). This object contains an `enabled` flag prop and an optional `transactionHandler` which is a callback that can disable or allow customizations of notifications. +Currently notifications are positioned in the same location as the account center (either below, if the Account Center is positioned along the top, or above if positioned on the bottom of the view). +The `transactionHandler` can react off any property of the Ethereum TransactionData returned to the callback from the event (see console.log in example init). In turn, it can return a Custom `Notification` object to define the verbiage, styling, or add functionality: + - `Notification.message` - to completely customize the message shown + - `Notification.eventCode` - handle codes in your own way - see codes here under the notify prop [default en file here](src/i18n/en.json) + - `Notification.type` - icon type displayed (see `NotificationType` below for options) + - `Notification.autoDismiss` - time (in ms) after which the notification will be dismissed. If set to `0` the notification will remain on screen until the user dismisses the notification, refreshes the page or navigates away from the site with the notifications + - `Notification.link` - add link to the transaction hash. For instance, a link to the transaction on etherscan + - `Notification.onClick()` - onClick handler for when user clicks the notification element + + Notify can also be styled by using the CSS variables found below. These are setup to allow maximum customization with base styling variables setting the global theme (i.e. `--onboard-grey-600`) along with more precise component level styling variables available (`--notify-onboard-grey-600`) with the latter taking precedent if defined + + If notifications are enabled the notifications can be handled through onboard app state as seen below. + ```javascript +const wallets = onboard.state.select('notifications') +const { unsubscribe } = wallets.subscribe(update => + console.log('transaction notifications: ', update) +) + +// unsubscribe when updates are no longer needed +unsubscribe() +``` + +```typescript +export type NotifyOptions = { + enabled: boolean // default: true + /** + * Callback that receives all transaction events + * Return a custom notification based on the event + * Or return false to disable notification for this event + * Or return undefined for a default notification + */ + transactionHandler?: ( + event: EthereumTransactionData + ) => TransactionHandlerReturn +} + +export type TransactionHandlerReturn = CustomNotification | boolean | void + +export type CustomNotification = Partial> + +export type Notification = { + id: string + key: string + type: NotificationType + network: Network + startTime?: number + eventCode: string + message: string + autoDismiss: number + link?: string + onClick?: (event: Event) => void +} + +export type NotificationType = 'pending' | 'success' | 'error' | 'hint' + +export declare type Network = 'main' | 'testnet' | 'ropsten' | 'rinkeby' | 'goerli' | 'kovan' | 'xdai' | 'bsc-main' | 'matic-main' | 'fantom-main' | 'matic-mumbai' | 'local'; + +export interface UpdateNotification { + (notificationObject: CustomNotification): { + dismiss: () => void + update: UpdateNotification + } +} +``` + +Notify can be used to deliver custom DApp notifications by passing a `CustomNotification` object to the `customNotification` action. This will return an `UpdateNotification` type. + This `UpdateNotification` will return an `update` function that can be passed a new `CustomNotification` to update the existing notification. + The `customNotification` method also returns a `dismiss` method that is called without any parameters to dismiss the notification. + +```typescript + const { update, dismiss } = + onboard.state.actions.customNotification({ + type: 'pending', + message: + 'This is a custom DApp pending notification to use however you want', + autoDismiss: 0 + }) + setTimeout( + () => + update({ + eventCode: 'dbUpdateSuccess', + message: 'Updated status for custom notification', + type: 'success', + autoDismiss: 8000 + }), + 4000 + ) +``` + ### Initialization Example Putting it all together, here is an example initialization with the injected wallet modules: @@ -174,6 +271,19 @@ const onboard = Onboard({ { name: 'Coinbase', url: 'https://wallet.coinbase.com/' } ] }, + apiKey: 'xxx387fb-bxx1-4xxc-a0x3-9d37e426xxxx' + notify: { + enabled: true, + transactionHandler: transaction => { + console.log({ transaction }) + if (transaction.eventCode === 'txPool') { + return { + type: 'success', + message: 'Your transaction from #1 DApp is in the mempool', + } + } + } + }, accountCenter: { desktop: { position: 'topRight', @@ -192,6 +302,20 @@ const onboard = Onboard({ selectingWallet: { header: 'custom text header' } + }, + notify: { + transaction: { + txStuck: 'custom text for this notification event' + }, + watched: { + // Any words in brackets can be re-ordered or removed to fit your dapps desired verbiage + "txPool": "Your account is {verb} {formattedValue} {asset} {preposition} {counterpartyShortened}" + } + } + }, + es: { + transaction: { + txRequest: 'Su transacción está esperando que confirme' } } } @@ -283,6 +407,9 @@ type AppState = { chains: Chain[] accountCenter: AccountCenter walletModules: WalletModule[] + locale: Locale + notify: NotifyOptions + notifications: Notification[] } type Chain { @@ -603,6 +730,19 @@ The Onboard styles can customized via [CSS variables](https://developer.mozilla. /* SPACING */ --account-select-modal-margin-4: 1rem; --account-select-modal-margin-5: 0.5rem; + + /* notify STYLES */ + --notify-onboard-font-family-normal + --notify-onboard-font-size-5 + --notify-onboard-gray-300 + --notify-onboard-gray-600 + --notify-onboard-border-radius + --notify-onboard-font-size-7 + --notify-onboard-font-size-6 + --notify-onboard-line-height-4 + --notify-onboard-primary-100 + --notify-onboard-primary-400 + --notify-onboard-main-padding } ``` diff --git a/packages/core/package.json b/packages/core/package.json index ddf61f936..f2d138f5f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@web3-onboard/core", - "version": "2.2.12", + "version": "2.3.0", "scripts": { "build": "rollup -c", "dev": "rollup -c -w", @@ -41,13 +41,16 @@ "typescript": "^4.5.5" }, "dependencies": { - "@web3-onboard/common": "^2.1.1", + "@web3-onboard/common": "^2.1.2", + "bignumber.js": "^9.0.0", + "bnc-sdk": "^4.4.1", "bowser": "^2.11.0", "ethers": "5.5.3", "eventemitter3": "^4.0.7", "joi": "17.6.0", "lodash.merge": "^4.6.2", "lodash.partition": "^4.6.0", + "nanoid": "^4.0.0", "rxjs": "^7.5.2", "svelte": "^3.46.4", "svelte-i18n": "^3.3.13" diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js index 6c3f90353..0995d1e78 100644 --- a/packages/core/rollup.config.js +++ b/packages/core/rollup.config.js @@ -4,7 +4,7 @@ import replace from '@rollup/plugin-replace' import json from '@rollup/plugin-json' import sveltePreprocess from 'svelte-preprocess' import typescript from '@rollup/plugin-typescript' -import copy from '@rollup-extras/plugin-copy'; +import copy from '@rollup-extras/plugin-copy' const production = !process.env.ROLLUP_WATCH @@ -51,6 +51,9 @@ export default { 'svelte/store', 'lodash.merge', 'lodash.partition', - 'eventemitter3' + 'eventemitter3', + 'bignumber.js', + 'bnc-sdk', + 'nanoid' ] } diff --git a/packages/core/src/configuration.ts b/packages/core/src/configuration.ts new file mode 100644 index 000000000..b9a065755 --- /dev/null +++ b/packages/core/src/configuration.ts @@ -0,0 +1,13 @@ +import type { Configuration } from './types' +import { getDevice } from './utils' + +export let configuration: Configuration = { + svelteInstance: null, + appMetadata: null, + apiKey: null, + device: getDevice() +} + +export function updateConfiguration(update: Partial): void { + configuration = { ...configuration, ...update } +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 4aac03729..32bed8402 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,4 +1,4 @@ -import { internalState } from './internals' +import { configuration } from './configuration' import type { AppState } from './types' export const APP_INITIAL_STATE: AppState = { @@ -9,8 +9,13 @@ export const APP_INITIAL_STATE: AppState = { enabled: true, position: 'topRight', expanded: false, - minimal: internalState.device.type === 'mobile' + minimal: configuration.device.type === 'mobile' }, + notify: { + enabled: true, + transactionHandler: () => {} + }, + notifications: [], locale: '' } diff --git a/packages/core/src/disconnect.ts b/packages/core/src/disconnect.ts index 7f3a3c7f7..618cd3c54 100644 --- a/packages/core/src/disconnect.ts +++ b/packages/core/src/disconnect.ts @@ -1,3 +1,4 @@ +import { getBlocknativeSdk } from './services' import { state } from './store' import { removeWallet } from './store/actions' import { disconnectWallet$ } from './streams' @@ -12,6 +13,23 @@ async function disconnect(options: DisconnectOptions): Promise { const { label } = options + if (state.get().notify.enabled) { + // handle unwatching addresses + const sdk = await getBlocknativeSdk() + + if (sdk) { + const wallet = state.get().wallets.find(wallet => wallet.label === label) + + wallet.accounts.forEach(({ address }) => { + sdk.unsubscribe({ + id: address, + chainId: wallet.chains[0].id, + timeout: 60000 + }) + }) + } + } + disconnectWallet$.next(label) removeWallet(label) diff --git a/packages/core/src/i18n/en.json b/packages/core/src/i18n/en.json index 52d17ebc3..5078a9464 100644 --- a/packages/core/src/i18n/en.json +++ b/packages/core/src/i18n/en.json @@ -71,5 +71,37 @@ "addAccount": "Add Account", "setPrimaryAccount": "Set Primary Account", "disconnectWallet": "Disconnect Wallet" + }, + "notify": { + "transaction": { + "txRequest": "Your transaction is waiting for you to confirm", + "nsfFail": "You have insufficient funds to complete this transaction", + "txUnderpriced": "The gas price for your transaction is too low, try again with a higher gas price", + "txRepeat": "This could be a repeat transaction", + "txAwaitingApproval": "You have a previous transaction waiting for you to confirm", + "txConfirmReminder": "Please confirm your transaction to continue, the transaction window may be behind your browser", + "txSendFail": "You rejected the transaction", + "txSent": "Your transaction has been sent to the network", + "txStallPending": "Your transaction has stalled and has not entered the transaction pool", + "txStuck": "Your transaction is stuck due to a nonce gap", + "txPool": "Your transaction has started", + "txStallConfirmed": "Your transaction has stalled and hasn't been confirmed", + "txSpeedUp": "Your transaction has been sped up", + "txCancel": "Your transaction is being canceled", + "txFailed": "Your transaction has failed", + "txConfirmed": "Your transaction has succeeded", + "txError": "Oops something went wrong, please try again" + }, + "watched": { + "txPool": "Your account is {verb} {formattedValue} {asset} {preposition} {counterpartyShortened}", + "txSpeedUp": "Transaction for {formattedValue} {asset} {preposition} {counterpartyShortened} has been sped up", + "txCancel": "Transaction for {formattedValue} {asset} {preposition} {counterpartyShortened} has been canceled", + "txConfirmed": "Your account successfully {verb} {formattedValue} {asset} {preposition} {counterpartyShortened}", + "txFailed": "Your account failed to {verb} {formattedValue} {asset} {preposition} {counterpartyShortened}" + }, + "time": { + "minutes": "min", + "seconds": "sec" + } } } diff --git a/packages/core/src/icons/checkmark.ts b/packages/core/src/icons/checkmark.ts new file mode 100644 index 000000000..9bcddb267 --- /dev/null +++ b/packages/core/src/icons/checkmark.ts @@ -0,0 +1,5 @@ +export default ` + + + +` diff --git a/packages/core/src/icons/close-circle.ts b/packages/core/src/icons/close-circle.ts new file mode 100644 index 000000000..99288ef30 --- /dev/null +++ b/packages/core/src/icons/close-circle.ts @@ -0,0 +1,5 @@ +export default ` + + + +` diff --git a/packages/core/src/icons/error.ts b/packages/core/src/icons/error.ts new file mode 100644 index 000000000..7b6a96ad8 --- /dev/null +++ b/packages/core/src/icons/error.ts @@ -0,0 +1,4 @@ +export default ` + + +` diff --git a/packages/core/src/icons/hourglass.ts b/packages/core/src/icons/hourglass.ts new file mode 100644 index 000000000..3dcee91f0 --- /dev/null +++ b/packages/core/src/icons/hourglass.ts @@ -0,0 +1,5 @@ +export default ` + + + +` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20405eafb..bf9c0a384 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,20 +3,23 @@ import connectWallet from './connect' import disconnectWallet from './disconnect' import setChain from './chain' import { state } from './store' +import { reset$ } from './streams' +import { validateInitOptions } from './validation' +import initI18N from './i18n' +import App from './views/Index.svelte' +import type { InitOptions, OnboardAPI } from './types' +import { APP_INITIAL_STATE } from './constants' +import { configuration, updateConfiguration } from './configuration' + import { addChains, setWalletModules, updateAccountCenter, + updateNotify, + customNotification, setLocale } from './store/actions' -import { reset$ } from './streams' -import { validateInitOptions } from './validation' -import initI18N from './i18n' -import App from './views/Index.svelte' -import type { InitOptions, OnboardAPI } from './types' -import { APP_INITIAL_STATE } from './constants' -import { internalState } from './internals' import updateBalances from './updateBalances' const API = { @@ -29,6 +32,8 @@ const API = { actions: { setWalletModules, setLocale, + updateNotify, + customNotification, updateBalances } } @@ -56,12 +61,20 @@ function init(options: InitOptions): OnboardAPI { } } - const { wallets, chains, appMetadata = null, i18n, accountCenter } = options + const { + wallets, + chains, + appMetadata = null, + i18n, + accountCenter, + apiKey, + notify + } = options initI18N(i18n) addChains(chains) - const { device, svelteInstance } = internalState + const { device, svelteInstance } = configuration // update accountCenter if (typeof accountCenter !== 'undefined') { @@ -82,6 +95,11 @@ function init(options: InitOptions): OnboardAPI { updateAccountCenter(accountCenterUpdate) } + // update notify + if (typeof notify !== undefined) { + updateNotify(notify) + } + if (svelteInstance) { // if already initialized, need to cleanup old instance console.warn('Re-initializing Onboard and resetting back to initial state') @@ -90,9 +108,11 @@ function init(options: InitOptions): OnboardAPI { const app = svelteInstance || mountApp() - // update metadata and app internal state - internalState.appMetadata = appMetadata - internalState.svelteInstance = app + updateConfiguration({ + appMetadata, + svelteInstance: app, + apiKey + }) setWalletModules(wallets) @@ -193,10 +213,10 @@ function mountApp() { --spacing-7: 0.125rem; /* BORDER RADIUS */ - --border-radius-1: 24px; - --border-radius-2: 20px; - --border-radius-3: 16px; - + --border-radius-1: 24px; + --border-radius-2: 20px; + --border-radius-3: 16px; + --border-radius-4: 12px; /* SHADOWS */ --shadow-0: none; @@ -226,4 +246,4 @@ function mountApp() { return app } -export default init \ No newline at end of file +export default init diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts deleted file mode 100644 index d12c51eb4..000000000 --- a/packages/core/src/internals.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { InternalState } from './types' -import { getDevice } from './utils' - -export const internalState: InternalState = { - svelteInstance: null, - appMetadata: null, - device: getDevice() -} diff --git a/packages/core/src/notify.ts b/packages/core/src/notify.ts new file mode 100644 index 000000000..c2500d2e7 --- /dev/null +++ b/packages/core/src/notify.ts @@ -0,0 +1,163 @@ +import BigNumber from 'bignumber.js' +import { get } from 'svelte/store' +import { _ } from 'svelte-i18n' +import defaultCopy from './i18n/en.json' +import type { EthereumTransactionData } from 'bnc-sdk' + +import type { + CustomNotification, + Notification, + NotificationType +} from './types' + +import { validateTransactionHandlerReturn } from './validation' +import { state } from './store' +import { addNotification } from './store/actions' + +export function handleTransactionUpdates( + transaction: EthereumTransactionData +): void { + const customized = state.get().notify.transactionHandler(transaction) + const invalid = validateTransactionHandlerReturn(customized) + + if (invalid) { + throw invalid + } + + const notification = transactionEventToNotification(transaction, customized) + + addNotification(notification) +} + +export function transactionEventToNotification( + transaction: EthereumTransactionData, + customization: CustomNotification | boolean | void +): Notification { + const { + id, + hash, + startTime, + eventCode, + direction, + counterparty, + value, + asset, + network + } = transaction + + const type: NotificationType = eventToType(eventCode) + + const key = `${id || hash}-${ + (typeof customization === 'object' && customization.eventCode) || eventCode + }` + + const counterpartyShortened: string | undefined = + counterparty && + counterparty.substring(0, 4) + + '...' + + counterparty.substring(counterparty.length - 4) + + const formattedValue = new BigNumber(value || 0) + .div(new BigNumber('1000000000000000000')) + .toString(10) + + const formatterOptions = + counterparty && value + ? { + messageId: `notify.watched['${eventCode}']`, + values: { + verb: + eventCode === 'txConfirmed' + ? direction === 'incoming' + ? 'received' + : 'sent' + : direction === 'incoming' + ? 'receiving' + : 'sending', + formattedValue, + preposition: direction === 'incoming' ? 'from' : 'to', + counterpartyShortened, + asset + } + } + : { + messageId: `notify.transaction['${eventCode}']`, + values: { formattedValue, asset } + } + + const formatter = get(_) + + const notificationDefaultMessages = defaultCopy.notify + + const typeKey: keyof typeof notificationDefaultMessages = counterparty + ? 'watched' + : 'transaction' + + const notificationMessageType = notificationDefaultMessages[typeKey] + + const defaultMessage = + notificationMessageType[eventCode as keyof typeof notificationMessageType] + + const message = formatter(formatterOptions.messageId, { + values: formatterOptions.values, + default: defaultMessage + }) + + let notification = { + id: id || hash, + type, + key, + network, + startTime: startTime || Date.now(), + eventCode, + message, + autoDismiss: typeToDismissTimeout( + (typeof customization === 'object' && customization.type) || type + ) + } + + if (typeof customization === 'object') { + notification = { ...notification, ...customization } + } + + return notification +} + +export function eventToType(eventCode: string | undefined): NotificationType { + switch (eventCode) { + case 'txSent': + case 'txPool': + return 'pending' + case 'txSpeedUp': + case 'txCancel': + case 'txRequest': + case 'txRepeat': + case 'txAwaitingApproval': + case 'txConfirmReminder': + case 'txStallPending': + case 'txStallConfirmed': + case 'txStuck': + return 'hint' + case 'txError': + case 'txSendFail': + case 'txFailed': + case 'txDropped': + case 'nsfFail': + case 'txUnderpriced': + return 'error' + case 'txConfirmed': + return 'success' + default: + return 'hint' + } +} + +export function typeToDismissTimeout(type: string): number { + switch (type) { + case 'success': + case 'hint': + return 4000 + default: + return 0 + } +} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index afd461008..b3e3bc313 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -20,6 +20,7 @@ import { updateAccount, updateWallet } from './store/actions' import { validEnsChain } from './utils' import disconnect from './disconnect' import { state } from './store' +import { getBlocknativeSdk } from './services' export const ethersProviders: { [key: string]: providers.StaticJsonRpcProvider @@ -30,7 +31,7 @@ export function getProvider(chain: Chain): providers.StaticJsonRpcProvider { if (!ethersProviders[chain.rpcUrl]) { ethersProviders[chain.rpcUrl] = new providers.StaticJsonRpcProvider( - chain.providerConnectionInfo?.url + chain.providerConnectionInfo && chain.providerConnectionInfo.url ? chain.providerConnectionInfo : chain.rpcUrl ) @@ -108,8 +109,8 @@ export function trackWallet( disconnected$ }).pipe(share()) - // when account changed, set it to first account - accountsChanged$.subscribe(([address]) => { + // when account changed, set it to first account and subscribe to events + accountsChanged$.subscribe(async ([address]) => { // no address, then no account connected, so disconnect wallet // this could happen if user locks wallet, // or if disconnects app from wallet @@ -133,9 +134,30 @@ export function trackWallet( ...restAccounts ] }) + + // if not existing account and notifications, + // then subscribe to transaction events + if (state.get().notify.enabled && !existingAccount) { + const sdk = await getBlocknativeSdk() + + if (sdk) { + const wallet = state + .get() + .wallets.find(wallet => wallet.label === label) + try { + sdk.subscribe({ + id: address, + chainId: wallet.chains[0].id, + type: 'account' + }) + } catch (error) { + // unsupported network for transaction events + } + } + } }) - // also when accounts change update Balance and ENS + // also when accounts change, update Balance and ENS accountsChanged$ .pipe( switchMap(async ([address]) => { @@ -177,13 +199,46 @@ export function trackWallet( ) // Update chain on wallet when chainId changed - chainChanged$.subscribe(chainId => { + chainChanged$.subscribe(async chainId => { const { wallets } = state.get() const { chains, accounts } = wallets.find(wallet => wallet.label === label) const [connectedWalletChain] = chains if (chainId === connectedWalletChain.id) return + if (state.get().notify.enabled) { + const sdk = await getBlocknativeSdk() + + if (sdk) { + const wallet = state + .get() + .wallets.find(wallet => wallet.label === label) + + // Unsubscribe with timeout of 60 seconds + // to allow for any currently inflight transactions + wallet.accounts.forEach(({ address }) => { + sdk.unsubscribe({ + id: address, + chainId: wallet.chains[0].id, + timeout: 60000 + }) + }) + + // resubscribe for new chainId + wallet.accounts.forEach(({ address }) => { + try { + sdk.subscribe({ + id: address, + chainId: chainId, + type: 'account' + }) + } catch (error) { + // unsupported network for transaction events + } + }) + } + } + const resetAccounts = accounts.map( ({ address }) => ({ @@ -245,7 +300,7 @@ export async function getEns( // chain we don't recognize and don't have a rpcUrl for requests if (!chain) return null - const provider = getProvider(chain); + const provider = getProvider(chain) try { const name = await provider.lookupAddress(address) @@ -285,7 +340,7 @@ export async function getBalance( // chain we don't recognize and don't have a rpcUrl for requests if (!chain) return null - const provider = getProvider(chain); + const provider = getProvider(chain) try { const balanceWei = await provider.getBalance(address) @@ -324,7 +379,10 @@ export function addNewChain( symbol: chain.token, decimals: 18 }, - rpcUrls: [chain.rpcUrl] + rpcUrls: [chain.publicRpcUrl || chain.rpcUrl], + blockExplorerUrls: chain.blockExplorerUrl + ? [chain.blockExplorerUrl] + : undefined } ] }) diff --git a/packages/core/src/services.ts b/packages/core/src/services.ts new file mode 100644 index 000000000..05f7a8ac8 --- /dev/null +++ b/packages/core/src/services.ts @@ -0,0 +1,26 @@ +import type { MultiChain } from 'bnc-sdk' +import { configuration } from './configuration' +import { handleTransactionUpdates } from './notify' + +let blocknativeSdk: MultiChain + +/** + * + * @returns SDK if apikey + */ +export async function getBlocknativeSdk(): Promise { + const { apiKey } = configuration + + if (!apiKey) return null + + if (!blocknativeSdk) { + const { default: Blocknative } = await import('bnc-sdk') + blocknativeSdk = Blocknative.multichain({ + apiKey: configuration.apiKey + }) + + blocknativeSdk.transactions$.subscribe(handleTransactionUpdates) + } + + return blocknativeSdk +} diff --git a/packages/core/src/store/actions.ts b/packages/core/src/store/actions.ts index d3a7c26ed..d7f9061d7 100644 --- a/packages/core/src/store/actions.ts +++ b/packages/core/src/store/actions.ts @@ -1,5 +1,7 @@ import type { Chain, WalletInit, WalletModule } from '@web3-onboard/common' +import { nanoid } from 'nanoid' import { dispatch } from './index' +import { configuration } from '../configuration' import type { Account, @@ -14,12 +16,24 @@ import type { UpdateAccountCenterAction, UpdateWalletAction, WalletState, - UpdateAllWalletsAction + NotifyOptions, + UpdateNotifyAction, + Notification, + AddNotificationAction, + RemoveNotificationAction, + UpdateAllWalletsAction, + CustomNotification, + UpdateNotification, + CustomNotificationUpdate } from '../types' import { validateAccountCenterUpdate, validateLocale, + validateNotification, + validateCustomNotification, + validateCustomNotificationUpdate, + validateNotifyOptions, validateString, validateWallet, validateWalletInit, @@ -34,11 +48,13 @@ import { REMOVE_WALLET, UPDATE_ACCOUNT, UPDATE_ACCOUNT_CENTER, + UPDATE_NOTIFY, SET_WALLET_MODULES, SET_LOCALE, + ADD_NOTIFICATION, + REMOVE_NOTIFICATION, UPDATE_ALL_WALLETS } from './constants' -import { internalState } from '../internals' export function addChains(chains: Chain[]): void { // chains are validated on init @@ -47,7 +63,7 @@ export function addChains(chains: Chain[]): void { payload: chains.map(({ namespace = 'evm', id, ...rest }) => ({ ...rest, namespace, - id : id.toLowerCase() + id: id.toLowerCase() })) } @@ -140,6 +156,121 @@ export function updateAccountCenter( dispatch(action as UpdateAccountCenterAction) } +export function updateNotify(update: Partial): void { + const error = validateNotifyOptions(update) + + if (error) { + throw error + } + + const action = { + type: UPDATE_NOTIFY, + payload: update + } + + dispatch(action as UpdateNotifyAction) +} + +export function addNotification(notification: Notification): void { + const error = validateNotification(notification) + + if (error) { + throw error + } + + const action = { + type: ADD_NOTIFICATION, + payload: notification + } + + dispatch(action as AddNotificationAction) +} + +export function addCustomNotification( + notification: CustomNotificationUpdate +): void { + const customNotificationError = validateCustomNotificationUpdate(notification) + + if (customNotificationError) { + throw customNotificationError + } + + const action = { + type: ADD_NOTIFICATION, + payload: notification + } + + dispatch(action as AddNotificationAction) +} + +export function customNotification(updatedNotification: CustomNotification): { + dismiss: () => void + update: UpdateNotification +} { + const customNotificationError = + validateCustomNotification(updatedNotification) + + if (customNotificationError) { + throw customNotificationError + } + + const customIdKey = `customNotification-${nanoid()}` + const notification: CustomNotificationUpdate = { + ...updatedNotification, + id: customIdKey, + key: customIdKey + } + addCustomNotification(notification) + + const dismiss = () => removeNotification(notification.id) + + const update = ( + notificationUpdate: CustomNotification + ): { + dismiss: () => void + update: UpdateNotification + } => { + const customNotificationError = + validateCustomNotification(updatedNotification) + + if (customNotificationError) { + throw customNotificationError + } + + const notificationAfterUpdate: CustomNotificationUpdate = { + ...notificationUpdate, + id: notification.id, + key: notification.key + } + addCustomNotification(notificationAfterUpdate) + + return { + dismiss, + update + } + } + + addCustomNotification(notification) + + return { + dismiss, + update + } +} + +export function removeNotification(id: Notification['id']): void { + if (typeof id !== 'string') { + throw new Error('Notification id must be of type string') + } + + const action = { + type: REMOVE_NOTIFICATION, + payload: id + } + + dispatch(action as RemoveNotificationAction) +} + export function resetStore(): void { const action = { type: RESET_STORE @@ -183,11 +314,11 @@ export function setLocale(locale: string): void { export function updateAllWallets(wallets: WalletState[]): void { const error = validateUpdateBalances(wallets) - + if (error) { throw error } - + const action = { type: UPDATE_ALL_WALLETS, payload: wallets @@ -198,7 +329,7 @@ export function updateAllWallets(wallets: WalletState[]): void { // ==== HELPERS ==== // export function initializeWalletModules(modules: WalletInit[]): WalletModule[] { - const { device } = internalState + const { device } = configuration return modules.reduce((acc, walletInit) => { const initialized = walletInit({ device }) diff --git a/packages/core/src/store/constants.ts b/packages/core/src/store/constants.ts index b54afcef3..ddf8e02fd 100644 --- a/packages/core/src/store/constants.ts +++ b/packages/core/src/store/constants.ts @@ -6,5 +6,8 @@ export const REMOVE_WALLET = 'remove_wallet' export const UPDATE_ACCOUNT = 'update_account' export const UPDATE_ACCOUNT_CENTER = 'update_account_center' export const SET_WALLET_MODULES = 'set_wallet_modules' -export const SET_LOCALE= 'set_locale' +export const SET_LOCALE = 'set_locale' +export const UPDATE_NOTIFY = 'update_notify' +export const ADD_NOTIFICATION = 'add_notification' +export const REMOVE_NOTIFICATION = 'remove_notification' export const UPDATE_ALL_WALLETS = 'update_balance' diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index d7e45f45a..bafe3c580 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -1,10 +1,9 @@ import { BehaviorSubject, Subject, Observable } from 'rxjs' import { distinctUntilKeyChanged, pluck, filter } from 'rxjs/operators' import { locale } from 'svelte-i18n' -import type { Chain, WalletModule } from '@web3-onboard/common' - import { APP_INITIAL_STATE } from '../constants' import { notNullish } from '../utils' +import type { Chain, WalletModule } from '@web3-onboard/common' import type { AppState, @@ -15,6 +14,9 @@ import type { UpdateAccountAction, UpdateAccountCenterAction, Locale, + UpdateNotifyAction, + AddNotificationAction, + RemoveNotificationAction, UpdateAllWalletsAction } from '../types' @@ -26,8 +28,11 @@ import { RESET_STORE, UPDATE_ACCOUNT, UPDATE_ACCOUNT_CENTER, + UPDATE_NOTIFY, SET_WALLET_MODULES, SET_LOCALE, + ADD_NOTIFICATION, + REMOVE_NOTIFICATION, UPDATE_ALL_WALLETS } from './constants' @@ -74,6 +79,7 @@ function reducer(state: AppState, action: Action): AppState { case REMOVE_WALLET: { const update = payload as { id: string } + return { ...state, wallets: state.wallets.filter(({ label }) => label !== update.id) @@ -104,7 +110,7 @@ function reducer(state: AppState, action: Action): AppState { } } - case UPDATE_ALL_WALLETS : { + case UPDATE_ALL_WALLETS: { const updatedWallets = payload as UpdateAllWalletsAction['payload'] return { ...state, @@ -114,6 +120,7 @@ function reducer(state: AppState, action: Action): AppState { case UPDATE_ACCOUNT_CENTER: { const update = payload as UpdateAccountCenterAction['payload'] + return { ...state, accountCenter: { @@ -123,6 +130,51 @@ function reducer(state: AppState, action: Action): AppState { } } + case UPDATE_NOTIFY: { + const update = payload as UpdateNotifyAction['payload'] + + return { + ...state, + notify: { + ...state.notify, + ...update + } + } + } + + case ADD_NOTIFICATION: { + const update = payload as AddNotificationAction['payload'] + const notificationsUpdate = [...state.notifications] + + const notificationExistsIndex = notificationsUpdate.findIndex( + ({ id }) => id === update.id + ) + + if (notificationExistsIndex !== -1) { + // if notification with same id, replace it with update + notificationsUpdate[notificationExistsIndex] = update + } else { + // otherwise add it to the beginning of array as new notification + notificationsUpdate.unshift(update) + } + + return { + ...state, + notifications: notificationsUpdate + } + } + + case REMOVE_NOTIFICATION: { + const id = payload as RemoveNotificationAction['payload'] + + return { + ...state, + notifications: state.notifications.filter( + notification => notification.id !== id + ) + } + } + case SET_WALLET_MODULES: { return { ...state, @@ -186,4 +238,4 @@ function get(): AppState { export const state = { select, get -} \ No newline at end of file +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2ede5aecf..8a883db33 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,13 +15,38 @@ import type connect from './connect' import type disconnect from './disconnect' import type { state } from './store' import type en from './i18n/en.json' +import type { EthereumTransactionData, Network } from 'bnc-sdk' export interface InitOptions { + /** + * Wallet modules to be initialized and added to wallet selection modal + */ wallets: WalletInit[] + /** + * The chains that your app works with + */ chains: Chain[] + /** + * Additional metadata about your app to be displayed in the Onboard UI + */ appMetadata?: AppMetadata + /** + * Define custom copy for the 'en' locale or add locales to i18n your app + */ i18n?: i18nOptions + /** + * Customize the account center UI + */ accountCenter?: AccountCenterOptions + /** + * Opt in to Blocknative value add services (transaction updates) by providing + * your Blocknative API key, head to https://explorer.blocknative.com/account + */ + apiKey?: string + /** + * Transaction notification options + */ + notify?: Partial } export interface OnboardAPI { @@ -95,12 +120,15 @@ export interface AppState { wallets: WalletState[] accountCenter: AccountCenter locale: Locale + notify: NotifyOptions + notifications: Notification[] } -export type InternalState = { +export type Configuration = { svelteInstance: SvelteComponent | null appMetadata: AppMetadata | null device: Device | DeviceNotBrowser + apiKey: string } export type Locale = string @@ -125,6 +153,55 @@ export type AccountCenterOptions = { mobile: Omit } +export type NotifyOptions = { + /** + * Defines whether whether to subscribe to transaction events or not + * default: true + */ + enabled: boolean + /** + * Callback that receives all transaction events + * Return a custom notification based on the event + * Or return false to disable notification for this event + * Or return undefined for a default notification + */ + transactionHandler: ( + event: EthereumTransactionData + ) => TransactionHandlerReturn +} + +export type Notification = { + id: string + key: string + type: NotificationType + network: Network + startTime?: number + eventCode: string + message: string + autoDismiss: number + link?: string + onClick?: (event: Event) => void +} + +export type TransactionHandlerReturn = CustomNotification | boolean | void + +export type CustomNotification = Partial< + Omit +> + +export type CustomNotificationUpdate = Partial< + Omit +> + +export type NotificationType = 'pending' | 'success' | 'error' | 'hint' + +export interface UpdateNotification { + (notificationObject: CustomNotification): { + dismiss: () => void + update: UpdateNotification + } +} + // ==== ACTIONS ==== // export type Action = | AddChainsAction @@ -136,6 +213,9 @@ export type Action = | UpdateAccountCenterAction | SetWalletModulesAction | SetLocaleAction + | UpdateNotifyAction + | AddNotificationAction + | RemoveNotificationAction | UpdateAllWalletsAction export type AddChainsAction = { type: 'add_chains'; payload: Chain[] } @@ -176,6 +256,21 @@ export type SetLocaleAction = { payload: string } +export type UpdateNotifyAction = { + type: 'update_notify' + payload: Partial +} + +export type AddNotificationAction = { + type: 'add_notification' + payload: Notification +} + +export type RemoveNotificationAction = { + type: 'remove_notification' + payload: Notification['id'] +} + export type UpdateAllWalletsAction = { type: 'update_balance' payload: WalletState[] @@ -187,6 +282,13 @@ export type ChainStyle = { color: string } +export type NotifyEventStyles = { + backgroundColor: string + borderColor: string + eventIcon: string + iconColor?: string +} + export type DeviceNotBrowser = { type: null os: null diff --git a/packages/core/src/updateBalances.ts b/packages/core/src/updateBalances.ts index b2a82ec14..59aa8c584 100644 --- a/packages/core/src/updateBalances.ts +++ b/packages/core/src/updateBalances.ts @@ -2,32 +2,31 @@ import { state } from './store' import { getBalance } from './provider' import { updateAllWallets } from './store/actions' -async function updateBalances(addresses?: string[]): Promise { - const { wallets, chains } = state.get() +async function updateBalances(addresses?: string[]): Promise { + const { wallets, chains } = state.get() - const updatedWallets = await Promise.all( - wallets.map(async wallet => { - const chain = chains.find(({ id }) => id === wallet.chains[0].id) + const updatedWallets = await Promise.all( + wallets.map(async wallet => { + const chain = chains.find(({ id }) => id === wallet.chains[0].id) - const updatedAccounts = await Promise.all( - wallet.accounts.map(async account => { - // if no provided addresses, we want to update all balances - // otherwise check if address is in addresses array - if (!addresses || addresses.includes(account.address)) { + const updatedAccounts = await Promise.all( + wallet.accounts.map(async account => { + // if no provided addresses, we want to update all balances + // otherwise check if address is in addresses array + if (!addresses || addresses.includes(account.address)) { + const updatedBalance = await getBalance(account.address, chain) - const updatedBalance = await getBalance(account.address, chain) + return { ...account, balance: updatedBalance } + } - return { ...account, balance: updatedBalance } - } - - return account - }) - ) - return { ...wallet, accounts: updatedAccounts } + return account }) ) - - updateAllWallets(updatedWallets) + return { ...wallet, accounts: updatedAccounts } + }) + ) + + updateAllWallets(updatedWallets) } -export default updateBalances \ No newline at end of file +export default updateBalances diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index d195db50a..b94c17c68 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -6,7 +6,9 @@ import type { DeviceOS, DeviceType, ChainId, - Chain + Chain, + WalletInit, + WalletModule } from '@web3-onboard/common' import ethereumIcon from './icons/ethereum' @@ -21,7 +23,17 @@ import gnosisIcon from './icons/gnosis' import harmonyOneIcon from './icons/harmony-one' import arbitrumIcon from './icons/arbitrum' -import type { ChainStyle, ConnectedChain, DeviceNotBrowser } from './types' +import hourglass from './icons/hourglass' +import checkmark from './icons/checkmark' +import error from './icons/error' +import info from './icons/info' + +import type { + ChainStyle, + ConnectedChain, + DeviceNotBrowser, + NotifyEventStyles +} from './types' export function getDevice(): Device | DeviceNotBrowser { if (typeof window !== 'undefined') { @@ -89,6 +101,19 @@ export const chainIdToLabel: Record = { '0xa4b1': 'Arbitrum' } +export const networkToChainId: Record = { + main: '0x1', + ropsten: '0x3', + rinkeby: '0x4', + goerli: '0x5', + kovan: '0x2a', + xdai: '0x64', + 'bsc-main': '0x38', + 'matic-main': '0x89', + 'fantom-main': '0xfa', + 'matic-mumbai': '0x80001' +} + export const chainStyles: Record = { '0x1': { icon: ethereumIcon, @@ -168,3 +193,43 @@ export function connectedToValidAppChain( namespace === walletConnectedChain.namespace ) } + +export function initializeWalletModules( + modules: WalletInit[], + device: Device +): WalletModule[] { + return modules.reduce((acc, walletInit) => { + const initialized = walletInit({ device }) + + if (initialized) { + // injected wallets is an array of wallets + acc.push(...(Array.isArray(initialized) ? initialized : [initialized])) + } + + return acc + }, [] as WalletModule[]) +} + +export const defaultNotifyEventStyles: Record = { + pending: { + backgroundColor: 'var(--onboard-primary-700, var(--primary-700))', + borderColor: '#6370E5', + eventIcon: hourglass + }, + success: { + backgroundColor: '#052E17', + borderColor: 'var(--onboard-success-300, var(--success-300))', + eventIcon: checkmark + }, + error: { + backgroundColor: '#FDB1B11A', + borderColor: 'var(--onboard-danger-300, var(--danger-300))', + eventIcon: error + }, + hint: { + backgroundColor: 'var(--onboard-gray-500, var(--gray-500))', + borderColor: 'var(--onboard-gray-500, var(--gray-500))', + iconColor: 'var(--onboard-gray-100, var(--gray-100))', + eventIcon: info + } +} diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 14fc5bb05..a31081cf5 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -1,5 +1,10 @@ -import Joi from 'joi' -import type { ChainId, WalletInit, WalletModule } from '@web3-onboard/common' +import Joi, { ObjectSchema, Schema } from 'joi' +import type { + Chain, + ChainId, + WalletInit, + WalletModule +} from '@web3-onboard/common' import type { InitOptions, @@ -7,7 +12,12 @@ import type { ConnectOptions, DisconnectOptions, ConnectOptionsString, - AccountCenter + AccountCenter, + TransactionHandlerReturn, + NotifyOptions, + Notification, + CustomNotification, + CustomNotificationUpdate } from './types' const chainId = Joi.string().pattern(/^0x[0-9a-fA-F]+$/) @@ -28,7 +38,7 @@ const providerConnectionInfo = Joi.object({ timeout: Joi.number() }) -const chain = Joi.object({ +const chainValidationParams: Record = { namespace: chainNamespace, id: chainId.required(), rpcUrl: Joi.string().required(), @@ -36,8 +46,11 @@ const chain = Joi.object({ token: Joi.string().required(), icon: Joi.string(), color: Joi.string(), - providerConnectionInfo: providerConnectionInfo -}) + publicRpcUrl: Joi.string(), + blockExplorerUrl: Joi.string(), + providerConnectionInfo +} +const chain = Joi.object(chainValidationParams) const connectedChain = Joi.object({ namespace: chainNamespace.required(), @@ -122,11 +135,17 @@ const accountCenterPosition = Joi.string().valid( 'topLeft' ) +const notify = Joi.object({ + transactionHandler: Joi.function(), + enabled: Joi.boolean() +}) + const initOptions = Joi.object({ wallets: walletInit, chains: chains.required(), appMetadata: appMetadata, i18n: Joi.object().unknown(), + apiKey: Joi.string(), accountCenter: Joi.object({ desktop: Joi.object({ enabled: Joi.boolean(), @@ -136,9 +155,10 @@ const initOptions = Joi.object({ mobile: Joi.object({ enabled: Joi.boolean(), minimal: Joi.boolean(), - position: accountCenterPosition, + position: accountCenterPosition }) - }) + }), + notify }) const connectOptions = Joi.object({ @@ -168,6 +188,46 @@ const accountCenter = Joi.object({ minimal: Joi.boolean() }) +const customNotificationUpdate = Joi.object({ + key: Joi.string().required(), + type: Joi.string().allow('pending', 'error', 'success', 'hint'), + eventCode: Joi.string(), + message: Joi.string().required(), + id: Joi.string().required(), + autoDismiss: Joi.number(), + onClick: Joi.function(), + link: Joi.string() +}) + +const customNotification = Joi.object({ + key: Joi.string(), + type: Joi.string().allow('pending', 'error', 'success', 'hint'), + eventCode: Joi.string(), + message: Joi.string(), + id: Joi.string(), + autoDismiss: Joi.number(), + onClick: Joi.function(), + link: Joi.string() +}) + +const notification = Joi.object({ + id: Joi.string().required(), + key: Joi.string().required(), + type: Joi.string().allow('pending', 'error', 'success', 'hint').required(), + eventCode: Joi.string().required(), + message: Joi.string().required(), + autoDismiss: Joi.number().required(), + network: Joi.string().required(), + startTime: Joi.number(), + onClick: Joi.function(), + link: Joi.string() +}) + +const transactionHandlerReturn = Joi.any().allow( + customNotificationUpdate, + Joi.boolean().allow(false) +) + type ValidateReturn = Joi.ValidationResult | null function validate(validator: Joi.Schema, data: unknown): ValidateReturn { @@ -207,6 +267,7 @@ export function validateString(str: string): ValidateReturn { export function validateSetChainOptions(data: { chainId: ChainId + chainNamespace?: string wallet?: WalletState['label'] }): ValidateReturn { return validate(setChainOptions, data) @@ -226,7 +287,34 @@ export function validateLocale(data: string): ValidateReturn { return validate(locale, data) } -export function validateUpdateBalances(data: -WalletState[]): ValidateReturn { +export function validateNotifyOptions( + data: Partial +): ValidateReturn { + return validate(notify, data) +} + +export function validateTransactionHandlerReturn( + data: TransactionHandlerReturn +): ValidateReturn { + return validate(transactionHandlerReturn, data) +} + +export function validateNotification(data: Notification): ValidateReturn { + return validate(notification, data) +} + +export function validateCustomNotificationUpdate( + data: CustomNotificationUpdate +): ValidateReturn { + return validate(customNotificationUpdate, data) +} + +export function validateCustomNotification( + data: CustomNotification +): ValidateReturn { + return validate(customNotification, data) +} + +export function validateUpdateBalances(data: WalletState[]): ValidateReturn { return validate(wallets, data) } diff --git a/packages/core/src/views/Index.svelte b/packages/core/src/views/Index.svelte index 6adbe1326..f033f1477 100644 --- a/packages/core/src/views/Index.svelte +++ b/packages/core/src/views/Index.svelte @@ -6,10 +6,24 @@ import SwitchChain from './chain/SwitchChain.svelte' import ActionRequired from './connect/ActionRequired.svelte' import AccountCenter from './account-center/Index.svelte' + import Notify from './notify/Index.svelte' + import { configuration } from '../configuration' + const { device } = configuration const accountCenter$ = state .select('accountCenter') .pipe(startWith(state.get().accountCenter), shareReplay(1)) + + const notify$ = state + .select('notify') + .pipe(startWith(state.get().notify), shareReplay(1)) + + const accountCenterPositions = { + topLeft: 'top: 0; left: 0;', + topRight: 'top: 0; right: 0;', + bottomRight: 'bottom: 0; right: 0;', + bottomLeft: 'bottom: 0; left: 0;' + } @@ -245,6 +277,37 @@ {/if} -{#if $accountCenter$.enabled && $wallets$.length} - +{#if ($notify$.enabled || $accountCenter$.enabled) && $wallets$.length} +
+ {#if $notify$.enabled && $accountCenter$.position.includes('bottom')} + + {/if} +
+ {#if $accountCenter$.enabled && $wallets$.length} + + {/if} +
+ + {#if $notify$.enabled && $accountCenter$.position.includes('top')} + + {/if} +
{/if} diff --git a/packages/core/src/views/account-center/DisconnectAllConfirm.svelte b/packages/core/src/views/account-center/DisconnectAllConfirm.svelte index 5abae1a07..55e53c7bc 100644 --- a/packages/core/src/views/account-center/DisconnectAllConfirm.svelte +++ b/packages/core/src/views/account-center/DisconnectAllConfirm.svelte @@ -20,7 +20,7 @@ .icon-container { width: 3rem; height: 3rem; - background-color: var(--onboard-warning-100, var(--warning-100)); + background: var(--onboard-warning-100, var(--warning-100)); border-radius: 24px; padding: 12px; color: var(--onboard-warning-500, var(--warning-500)); diff --git a/packages/core/src/views/account-center/Index.svelte b/packages/core/src/views/account-center/Index.svelte index e3c36d474..f98af7de2 100644 --- a/packages/core/src/views/account-center/Index.svelte +++ b/packages/core/src/views/account-center/Index.svelte @@ -1,6 +1,5 @@ - - -
- {#if !settings.expanded && !settings.minimal} - - - {:else if !settings.expanded && settings.minimal} - - - {:else} - - - {/if} -
+{#if !settings.expanded && !settings.minimal} + + +{:else if !settings.expanded && settings.minimal} + + +{:else} + + +{/if} diff --git a/packages/core/src/views/account-center/Maximized.svelte b/packages/core/src/views/account-center/Maximized.svelte index 2f8d69a58..788dc4242 100644 --- a/packages/core/src/views/account-center/Maximized.svelte +++ b/packages/core/src/views/account-center/Maximized.svelte @@ -20,14 +20,14 @@ import { updateAccountCenter } from '../../store/actions' import blocknative from '../../icons/blocknative' import DisconnectAllConfirm from './DisconnectAllConfirm.svelte' - import { internalState } from '../../internals' + import { configuration } from '../../configuration' function disconnectAllWallets() { $wallets$.forEach(({ label }) => disconnect({ label })) } const { chains: appChains } = state.get() - const { appMetadata } = internalState + const { appMetadata } = configuration let disconnectConfirmModal = false let hideWalletRowMenu: () => void @@ -45,15 +45,17 @@ ) const { position } = state.get().accountCenter - const { device } = internalState + const { device } = configuration diff --git a/packages/core/src/views/chain/SwitchChain.svelte b/packages/core/src/views/chain/SwitchChain.svelte index c1692d058..67539295e 100644 --- a/packages/core/src/views/chain/SwitchChain.svelte +++ b/packages/core/src/views/chain/SwitchChain.svelte @@ -4,9 +4,9 @@ import en from '../../i18n/en.json' import CloseButton from '../shared/CloseButton.svelte' import Modal from '../shared/Modal.svelte' - import { internalState } from '../../internals' + import { configuration } from '../../configuration' - const { appMetadata } = internalState + const { appMetadata } = configuration const nextNetworkName = $switchChainModal$.chain.label function close() { diff --git a/packages/core/src/views/connect/ActionRequired.svelte b/packages/core/src/views/connect/ActionRequired.svelte index 840ad2ef8..e208b0fbb 100644 --- a/packages/core/src/views/connect/ActionRequired.svelte +++ b/packages/core/src/views/connect/ActionRequired.svelte @@ -27,7 +27,7 @@ .icon-container { width: 3rem; height: 3rem; - background-color: var(--onboard-primary-100, var(--primary-100)); + background: var(--onboard-primary-100, var(--primary-100)); border-radius: 24px; } diff --git a/packages/core/src/views/connect/Agreement.svelte b/packages/core/src/views/connect/Agreement.svelte index c58f3f881..42d85c246 100644 --- a/packages/core/src/views/connect/Agreement.svelte +++ b/packages/core/src/views/connect/Agreement.svelte @@ -1,7 +1,7 @@ + +
+ {@html icon} +
diff --git a/packages/core/src/views/notify/Index.svelte b/packages/core/src/views/notify/Index.svelte new file mode 100644 index 000000000..272722e02 --- /dev/null +++ b/packages/core/src/views/notify/Index.svelte @@ -0,0 +1,141 @@ + + + + +{#if $notifications$.length} +
    + {#each $notifications$ as notification (notification.key)} +
  • + +
  • + {/each} +
+{/if} diff --git a/packages/core/src/views/notify/Notification.svelte b/packages/core/src/views/notify/Notification.svelte new file mode 100644 index 000000000..2f59e6892 --- /dev/null +++ b/packages/core/src/views/notify/Notification.svelte @@ -0,0 +1,127 @@ + + + + +
notification.onClick && notification.onClick(e)} + class="bn-notify-notification bn-notify-notification-{notification.type}}" +> + + + +
{ + removeNotification(notification.id) + updateParentOnRemove() + }} + class="notify-close-btn notify-close-btn-{device.type} pointer flex" + > +
+ {@html closeIcon} +
+
+
diff --git a/packages/core/src/views/notify/NotificationContent.svelte b/packages/core/src/views/notify/NotificationContent.svelte new file mode 100644 index 000000000..7eb65a688 --- /dev/null +++ b/packages/core/src/views/notify/NotificationContent.svelte @@ -0,0 +1,96 @@ + + + + +
+ + {notification.message} + + + {#if notification.id && !notification.id.includes('customNotification')} + + {#if notification.link} + + {shortenAddress(notification.id)} + + {:else} +
+ {shortenAddress(notification.id)} +
+ {/if} + +
+ {/if} +
diff --git a/packages/core/src/views/notify/StatusIconBadge.svelte b/packages/core/src/views/notify/StatusIconBadge.svelte new file mode 100644 index 000000000..010adc6a9 --- /dev/null +++ b/packages/core/src/views/notify/StatusIconBadge.svelte @@ -0,0 +1,105 @@ + + + + +{#if notification.type} +
+ {#if notification.type === 'pending'} +
+ {/if} + +
+
+ {@html defaultNotifyEventStyles[notification.type]['eventIcon']} +
+
+ {#if !notification.id.includes('customNotification')} +
+ +
+ {/if} +
+{/if} diff --git a/packages/core/src/views/notify/Timer.svelte b/packages/core/src/views/notify/Timer.svelte new file mode 100644 index 000000000..f2c52677a --- /dev/null +++ b/packages/core/src/views/notify/Timer.svelte @@ -0,0 +1,63 @@ + + + + +
+ {#if startTime} + - + + {timeString(currentTime - startTime)} + + ago + {/if} +
diff --git a/packages/core/src/views/shared/CloseButton.svelte b/packages/core/src/views/shared/CloseButton.svelte index ef07847a5..96db260ac 100644 --- a/packages/core/src/views/shared/CloseButton.svelte +++ b/packages/core/src/views/shared/CloseButton.svelte @@ -1,28 +1,29 @@
-
-
{@html closeIcon}
+
+
+ {@html closeIcon} +
diff --git a/packages/core/src/views/shared/Modal.svelte b/packages/core/src/views/shared/Modal.svelte index db7f69ff1..9362d11c8 100644 --- a/packages/core/src/views/shared/Modal.svelte +++ b/packages/core/src/views/shared/Modal.svelte @@ -16,9 +16,9 @@
- - - {#if $wallets$} - - - - - {/if} +
+ + + {#if $wallets$} + +
+
+ + + + + +
+
+ + + +
+
+ {/if} +
{#if $wallets$} {#each $wallets$ as { icon, label, accounts, chains, provider }} @@ -300,6 +401,7 @@ {#each accounts as { address, ens, balance }}