├── .eslintignore ├── .eslintrc ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── npm-publish.yml │ ├── salus-scan.yml │ ├── static-analysis.yml │ └── unit-tests.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .swcrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── docs └── commit-signing.md ├── jest.config.js ├── jest.setup.js ├── package.json ├── src ├── config.ts ├── index.ts ├── offramp │ ├── generateOffRampURL.test.ts │ ├── generateOffRampURL.ts │ ├── initOffRamp.test.ts │ └── initOffRamp.ts ├── onramp │ ├── generateOnRampURL.test.ts │ ├── generateOnRampURL.ts │ ├── initOnRamp.test.ts │ └── initOnRamp.ts ├── types │ ├── JsonTypes.ts │ ├── events.ts │ ├── global.d.ts │ ├── offramp.ts │ ├── onramp.ts │ └── widget.ts └── utils │ ├── CBPayInstance.test.ts │ ├── CBPayInstance.ts │ ├── CoinbasePixel.test.ts │ ├── CoinbasePixel.ts │ ├── createEmbeddedContent.test.ts │ ├── createEmbeddedContent.ts │ ├── events.ts │ ├── postMessage.test.ts │ └── postMessage.ts ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier", "jest"], 4 | "extends": ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 5 | "parserOptions": { 6 | "ecmaFeatures": { 7 | "jsx": true 8 | }, 9 | "ecmaVersion": 12, 10 | "sourceType": "module" 11 | }, 12 | "env": { 13 | "node": true, 14 | "browser": true, 15 | "jest/globals": true 16 | }, 17 | "rules": { 18 | "@typescript-eslint/no-unused-vars": "error" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description & motivation 2 | 6 | 7 | 8 | 9 | 10 | ### Testing & documentation 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish to NPM 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 18 18 | - run: yarn install --frozen-lockfile 19 | - run: yarn test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | environment: release 25 | permissions: 26 | contents: read 27 | id-token: write 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 18 33 | registry-url: https://registry.npmjs.org/ 34 | - run: yarn install --frozen-lockfile 35 | - run: npm publish --provenance --access public 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 38 | -------------------------------------------------------------------------------- /.github/workflows/salus-scan.yml: -------------------------------------------------------------------------------- 1 | name: Salus Security Scan 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | scan: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Salus Scan 11 | id: salus_scan 12 | uses: federacy/scan-action@0.1.4 13 | with: 14 | active_scanners: "\n - PatternSearch\n - Semgrep\n - Trufflehog\n - YarnAudit" 15 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | static-analysis: 7 | name: 'Lint and Type-check' 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: 16 14 | - name: Install Dependencies 15 | uses: bahmutov/npm-install@v1 16 | - name: TypeCheck 17 | run: yarn typecheck 18 | - name: Lint 19 | run: yarn lint 20 | - name: Unit Tests 21 | run: yarn jest 22 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | matrix: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | latest: ${{ steps.set-matrix.outputs.requireds }} 10 | steps: 11 | - uses: ljharb/actions/node/matrix@main 12 | id: set-matrix 13 | with: 14 | versionsAsRoot: true 15 | type: majors 16 | preset: '^14 || >= 16' 17 | 18 | test: 19 | needs: [matrix] 20 | name: 'On Node ${{ matrix.node-version }}' 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | node-version: ${{ fromJson(needs.matrix.outputs.latest) }} 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - name: Install Dependencies 32 | uses: bahmutov/npm-install@v1 33 | - name: Unit Tests 34 | run: yarn jest 35 | 36 | tests: 37 | needs: [test] 38 | name: 'unit tests' 39 | runs-on: ubuntu-latest 40 | steps: 41 | - run: echo 'tests completed' 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cjs/ 3 | dist/ 4 | build/ 5 | lib/ 6 | .env 7 | npm-debug.log 8 | yarn-error.log 9 | package-lock.json 10 | .DS_Store 11 | lerna-debug.log 12 | .filmstrip 13 | *.tsbuildinfo 14 | 15 | package/ 16 | coinbase-cbpay-js-*.tgz 17 | .vscode -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.1 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "jsxSingleQuote": false, 5 | "printWidth": 100, 6 | "semi": true, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "all", 10 | "useTabs": false 11 | } -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/swcrc", 3 | "module": { 4 | "type": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [2.4.0] - 2024-11-08 6 | - Add `disableEdit` offramp parameter 7 | 8 | ## [2.3.0] - 2024-10-21 9 | - Add Offramp support with `generateOffRampURL` and `initOffRamp` functions 10 | 11 | ## [2.2.1] - 2024-08-01 12 | - Added `redirectUrl` widget parameter 13 | 14 | ## [2.2.0] - 2024-06-11 15 | - Added new `addresses` and `assets` initialization parameters to simplify `destinationWallets` 16 | - Marked the `destinationWallets` initialization parameter as deprecated 17 | - Added warning message about upcoming deprecation of the embedded experience 18 | - Simplified the `CoinbasePixel` class to improve widget initialization latency 19 | - Fix example code in README 20 | 21 | ## [2.1.0] - 2024-03-14 22 | - Add `theme` parameter 23 | 24 | ## [2.0.0] - 2024-01-25 25 | - [BREAKING CHANGE] Rename `onrampToken` parameter to `sessionToken` 26 | 27 | ## [1.10.0] - 2024-01-11 28 | - Add `onrampToken` parameter 29 | 30 | ## [1.9.0] - 2023-11-07 31 | - Add `partnerUserId` parameter 32 | 33 | ## [1.8.0] - 2023-10-12 34 | - Add support for Aggregator API parameters 35 | - Remove old SupportedBlockchains type 36 | - Hard coding this list into the SDK wasn't ideal 37 | - We support the expected set of assets + networks 38 | - We're adding an endpoint which lists the currently supported assets + networks. 39 | 40 | ## [1.7.0] - 2023-05-05 41 | - Add `defaultExperience` parameter 42 | - Add `handlingRequestedUrls` parameter and associated `request_open_url` event 43 | - Add support for several blockchains 44 | 45 | ## [1.6.0] - 2022-09-13 46 | - Move internal exports to main export file. 47 | 48 | ## [1.5.0] - 2022-09-13 49 | - Update package.json exports format. 50 | - Improvements to pixel error handling. 51 | 52 | ## [1.4.0] - 2022-09-06 53 | - Update exports for core logic. 54 | 55 | ## [1.3.0] - 2022-08-25 56 | - Fix invalid CommonJS output. 57 | - Fix passing generateOnRampURL experience options. 58 | - Update npm package files. 59 | - Deprecate onReady parameter. 60 | - Implement synchronous open function. 61 | 62 | ### Migration support for initOnRamp 63 | 64 | See the updated examples in the README.md. The `onReady` function has been updated to be a callback provided as the second argument in `initOnRamp`. This is to avoid race conditions which results in the widget failing to open in some cases. 65 | 66 | 67 | ## [1.2.0] - 2022-08-19 68 | - Improve pixel internal state and message management. 69 | - Implement fallback open functionality for initOnRamp. 70 | - Add debug option for initOnRamp. 71 | 72 | ## [1.1.0] - 2022-08-17 73 | 74 | - Add parameters to add options for L2 destination wallet configs. 75 | - Update generateOnRampURL formatting. 76 | - Remove the typing for blockchains parameter. 77 | - Add flow as supported network. 78 | 79 | 80 | ## [1.0.2] - 2022-06-30 81 | 82 | - Add preset amount parameters support. 83 | - Export init option types. 84 | - Update generateOnRampURL options. 85 | 86 | ## [1.0.1] - 2022-05-19 87 | 88 | - Add additional network support. 89 | - Upgrade tsup. 90 | - Update readme.md with examples and documentation. 91 | 92 | ## [1.0.0] - 2022-04-21 93 | 94 | - First release with `initOnRamp` and `generateOnRampURL` functions. 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Rest Hooks 2 | 3 | ## Code of Conduct 4 | 5 | All interactions with this project follow our [Code of Conduct][code-of-conduct]. 6 | By participating, you are expected to honor this code. Violators can be banned 7 | from further participation in this project, or potentially all Coinbase projects. 8 | 9 | [code-of-conduct]: https://github.com/coinbase/code-of-conduct 10 | 11 | ## Bug Reports 12 | 13 | * Ensure your issue [has not already been reported][1]. It may already be fixed! 14 | * Include the steps you carried out to produce the problem. 15 | * Include the behavior you observed along with the behavior you expected, and 16 | why you expected it. 17 | * Include any relevant stack traces or debugging output. 18 | 19 | ## Feature Requests 20 | 21 | We welcome feedback with or without pull requests. If you have an idea for how 22 | to improve the project, great! All we ask is that you take the time to write a 23 | clear and concise explanation of what need you are trying to solve. If you have 24 | thoughts on _how_ it can be solved, include those too! 25 | 26 | The best way to see a feature added, however, is to submit a pull request. 27 | 28 | ## Pull Requests 29 | 30 | * Before creating your pull request, it's usually worth asking if the code 31 | you're planning on writing will actually be considered for merging. You can 32 | do this by [opening an issue][1] and asking. It may also help give the 33 | maintainers context for when the time comes to review your code. 34 | 35 | * Ensure your [commit messages are well-written][2]. This can double as your 36 | pull request message, so it pays to take the time to write a clear message. 37 | 38 | * Add tests for your feature. You should be able to look at other tests for 39 | examples. If you're unsure, don't hesitate to [open an issue][1] and ask! 40 | 41 | * Submit your pull request! 42 | 43 | ## Support Requests 44 | 45 | For security reasons, any communication referencing support tickets for Coinbase 46 | products will be ignored. The request will have its content redacted and will 47 | be locked to prevent further discussion. 48 | 49 | All support requests must be made via [our support team][3]. 50 | 51 | [1]: https://github.com/coinbase/rest-hooks/issues 52 | [2]: https://medium.com/brigade-engineering/the-secrets-to-great-commit-messages-106fc0a92a25 53 | [3]: https://support.coinbase.com/customer/en/portal/articles/2288496-how-can-i-contact-coinbase-support- -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Coinbase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @coinbase/cbpay-js 2 | 3 | The Coinbase Onramp JS SDK contains helper methods to simplify integrating with our fiat onramp. Wallet providers and dapps can leverage Coinbase Onramp and let their users top up their self-custody wallets. 4 | 5 | ## Documentation 6 | 7 | See the [Coinbase Onramp documentation](https://docs.cdp.coinbase.com/onramp/docs/getting-started/) for instructions on how to onboard to Coinbase Onramp and get started. 8 | 9 | ## Installation 10 | 11 | With `yarn`: 12 | 13 | ```shell 14 | yarn add @coinbase/cbpay-js 15 | ``` 16 | 17 | With `npm`: 18 | 19 | ```shell 20 | npm install @coinbase/cbpay-js 21 | ``` 22 | 23 | The package is distributed as both ESModules and CommonJS. To use the CommonJS output, the `regenerator-runtime` package will also need to be installed: 24 | 25 | With `yarn`: 26 | 27 | ```shell 28 | yarn add regenerator-runtime 29 | ``` 30 | 31 | With `npm`: 32 | 33 | ```shell 34 | npm install regenerator-runtime 35 | ``` 36 | 37 | ## Basic example 38 | 39 | ```jsx 40 | import { initOnRamp } from '@coinbase/cbpay-js'; 41 | 42 | const options = { 43 | appId: '', 44 | widgetParameters: { 45 | // Specify the addresses and which networks they support 46 | addresses: { '0x0': ['ethereum','base'], 'bc1': ['bitcoin']}, 47 | // Filter the available assets on the above networks to just these ones 48 | assets: ['ETH','USDC','BTC'], 49 | }, 50 | onSuccess: () => { 51 | console.log('success'); 52 | }, 53 | onExit: () => { 54 | console.log('exit'); 55 | }, 56 | onEvent: (event) => { 57 | console.log('event', event); 58 | }, 59 | experienceLoggedIn: 'popup', 60 | experienceLoggedOut: 'popup', 61 | closeOnExit: true, 62 | closeOnSuccess: true, 63 | }; 64 | 65 | // Initialize the CB Pay instance 66 | let onrampInstance; 67 | initOnRamp(options, (error, instance) => { 68 | onrampInstance = instance; 69 | }); 70 | 71 | // Open the widget when the user clicks a button 72 | onrampInstance.open(); 73 | ``` 74 | 75 | ## React example 76 | 77 | ```tsx 78 | import { CBPayInstanceType, initOnRamp } from "@coinbase/cbpay-js"; 79 | import { useEffect, useState } from "react"; 80 | 81 | export const PayWithCoinbaseButton: React.FC = () => { 82 | const [onrampInstance, setOnrampInstance] = useState(); 83 | 84 | useEffect(() => { 85 | initOnRamp({ 86 | appId: '', 87 | widgetParameters: { 88 | // Specify the addresses and which networks they support 89 | addresses: { '0x0': ['ethereum','base'], 'bc1': ['bitcoin']}, 90 | // Filter the available assets on the above networks to just these ones 91 | assets: ['ETH','USDC','BTC'], 92 | }, 93 | onSuccess: () => { 94 | console.log('success'); 95 | }, 96 | onExit: () => { 97 | console.log('exit'); 98 | }, 99 | onEvent: (event) => { 100 | console.log('event', event); 101 | }, 102 | experienceLoggedIn: 'popup', 103 | experienceLoggedOut: 'popup', 104 | closeOnExit: true, 105 | closeOnSuccess: true, 106 | }, (_, instance) => { 107 | setOnrampInstance(instance); 108 | }); 109 | 110 | // When button unmounts destroy the instance 111 | return () => { 112 | onrampInstance?.destroy(); 113 | }; 114 | }, []); 115 | 116 | const handleClick = () => { 117 | onrampInstance?.open(); 118 | }; 119 | 120 | return ; 121 | }; 122 | ``` 123 | 124 | ## React-Native example 125 | 126 | ### Prerequisites 127 | 128 | ``` 129 | yarn add react-native-url-polyfill 130 | ``` 131 | 132 | ```tsx 133 | import React, { useMemo } from 'react' 134 | import { WebView } from 'react-native-webview' 135 | import { generateOnRampURL } from '@coinbase/cbpay-js' 136 | import 'react-native-url-polyfill/auto' 137 | 138 | const CoinbaseWebView = ({ currentAmount }) => { 139 | const coinbaseURL = useMemo(() => { 140 | const options = { 141 | appId: '', 142 | // Specify the addresses and which networks they support 143 | addresses: { '0x0': ['ethereum','base'], 'bc1': ['bitcoin']}, 144 | // Filter the available assets on the above networks to just these ones 145 | assets: ['ETH','USDC','BTC'], 146 | handlingRequestedUrls: true, 147 | presetCryptoAmount: currentAmount, 148 | } 149 | 150 | return generateOnRampURL(options) 151 | }, [currentAmount, destinationAddress]) 152 | 153 | const onMessage = useCallback((event) => { 154 | // Check for Success and Error Messages here 155 | console.log('onMessage', event.nativeEvent.data) 156 | try { 157 | const { data } = JSON.parse(event.nativeEvent.data); 158 | if (data.eventName === 'request_open_url') { 159 | viewUrlInSecondWebview(data.url); 160 | } 161 | } catch (error) { 162 | console.error(error); 163 | } 164 | }, []) 165 | 166 | return ( 167 | 168 | ) 169 | } 170 | 171 | export default CoinbaseWebView 172 | ``` 173 | 174 | ## Aggregator Example 175 | Review the [Coinbase Developer docs](https://docs.cdp.coinbase.com/onramp/docs/api-aggregating/) for how to produce the parameters for use within an on ramp aggregator. 176 | 177 | ```tsx 178 | import { CBPayInstanceType, initOnRamp } from "@coinbase/cbpay-js"; 179 | import { useEffect, useState } from "react"; 180 | 181 | export const PayWithCoinbaseButton: React.FC = () => { 182 | const [onrampInstance, setOnrampInstance] = useState(); 183 | 184 | useEffect(() => { 185 | initOnRamp({ 186 | appId: '', 187 | widgetParameters: { 188 | // Specify the addresses and which networks they support 189 | addresses: { '0x0': ['ethereum','base'], 'bc1': ['bitcoin']}, 190 | // Filter the available assets on the above networks to just these ones 191 | assets: ['ETH','USDC','BTC'], 192 | // Aggregator params are ignored unless they are all provided. 193 | // defaultNetwork is the exception - it's optional. 194 | quoteId: '', 195 | defaultAsset: 'USDC', 196 | defaultNetwork: 'base', 197 | defaultPaymentMethod: 'CARD', 198 | presetFiatAmount: 20, 199 | fiatCurrency: 'USD', 200 | }, 201 | onSuccess: () => { 202 | console.log('success'); 203 | }, 204 | onExit: () => { 205 | console.log('exit'); 206 | }, 207 | onEvent: (event) => { 208 | console.log('event', event); 209 | }, 210 | experienceLoggedIn: 'popup', 211 | experienceLoggedOut: 'popup', 212 | closeOnExit: true, 213 | closeOnSuccess: true, 214 | }, (_, instance) => { 215 | setOnrampInstance(instance); 216 | }); 217 | 218 | // When button unmounts destroy the instance 219 | return () => { 220 | onrampInstance?.destroy(); 221 | }; 222 | }, []); 223 | 224 | const handleClick = () => { 225 | onrampInstance?.open(); 226 | }; 227 | 228 | return ; 229 | }; 230 | ``` 231 | 232 | ## Contributing 233 | 234 | Commit signing is required for contributing to this repo. For details, see the docs on [contributing](./CONTRIBUTING.md) and [commit-signing](./docs/commit-signing.md). 235 | -------------------------------------------------------------------------------- /docs/commit-signing.md: -------------------------------------------------------------------------------- 1 | # Commit Signing 2 | 3 | This repository requires commit signing. This document outlines how to get commit signing setup for this repo. 4 | 5 | ## Generate a GPG key 6 | 7 | If you're on a Mac, you can install the `gpg` CLI via homebrew with 8 | 9 | ```shell 10 | brew install gpg 11 | ``` 12 | 13 | Then you can use the `gpg` tool to generate a keypair: 14 | 15 | ```shell 16 | gpg --default-new-key-algo rsa4096 --quick-generate-key --batch --passphrase "" "Firstname Lastname " 17 | ``` 18 | 19 | Replace `youremail@provider.com` with your actual email. **Leave the angle brackets though!** 20 | 21 | ## Upload your public key to GitHub 22 | 23 | From your GitHub settings (SSH & GPG Keys), you'll need to upload your recently-generated GPG public key. You can use the following command to grab that key: 24 | 25 | ```shell 26 | gpg --armor --export youremail@provider.com | pbcopy 27 | ``` 28 | 29 | This should copy your public key to your clipboard, and you can paste this into your GitHub settings page (for adding a new GPG key). 30 | 31 | ## Enabling git signing for this repo 32 | 33 | Now, let's set a few git options **local for this repo**. Make sure you're `cd`'d into this repo and then run the following. 34 | 35 | ```shell 36 | # Use your public GitHub credentials 37 | git config user.name "Firstname Lastname" 38 | git config user.email "youremail@provider.com" 39 | # Set up commit signing 40 | git config user.signingkey youremail@provider.com 41 | git config user.gpgsign true 42 | ``` 43 | 44 | Notice the *lack* of `--global` flag in those commands. This will set these git credentials/configuration for just this repository. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | transform: { 4 | '^.+\\.jsx?$': 'babel-jest', 5 | }, 6 | setupFilesAfterEnv: ['./jest.setup.js'], 7 | testMatch: ['/src/**/*.test.ts', '/src/**/*.test.js'], 8 | globals: { 9 | 'ts-jest': { 10 | isolatedModules: true, 11 | }, 12 | }, 13 | testEnvironment: 'jsdom', 14 | }; 15 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | Object.assign(global, require('jest-chrome')); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@coinbase/cbpay-js", 3 | "repository": "https://github.com/coinbase/cbpay-js", 4 | "version": "2.4.0", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "module": "dist/index.mjs", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "import": "./dist/index.mjs", 13 | "require": "./dist/index.js", 14 | "default": "./dist/index.js" 15 | }, 16 | "./package.json": "./package.json" 17 | }, 18 | "files": [ 19 | "dist", 20 | "CHANGELOG.md", 21 | "LICENSE.md", 22 | "README.md" 23 | ], 24 | "scripts": { 25 | "build": "tsup", 26 | "start": "npm-watch", 27 | "test": "jest", 28 | "test:watch": "jest --watch", 29 | "lint": "eslint . --ext .js,.ts,.tsx --quiet", 30 | "clean": "rimraf lib cjs *.tsbuildinfo cbpay-sdk*", 31 | "prepare": "yarn run build", 32 | "typecheck": "tsc --noEmit", 33 | "check-ci": "yarn run typecheck && yarn run lint && yarn run test" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.0.0", 37 | "@swc/core": "^1.2.237", 38 | "@types/chrome": "0.0.168", 39 | "@types/jest": "^27.0.2", 40 | "@types/node": "^17.0.21", 41 | "@typescript-eslint/eslint-plugin": "4.26.1", 42 | "@typescript-eslint/eslint-plugin-tslint": "^4.5.0", 43 | "@typescript-eslint/parser": "4.26.1", 44 | "babel-jest": "^27.5.1", 45 | "eslint": "^6.8.0", 46 | "eslint-config-prettier": "^8.4.0", 47 | "eslint-plugin-jest": "^22.15.2", 48 | "eslint-plugin-prettier": "^3.4.1", 49 | "jest": "^27.3.1", 50 | "jest-chrome": "^0.7.2", 51 | "prettier": "^2.5.1", 52 | "ts-jest": "^27.1.3", 53 | "tslint": "^6.0.0", 54 | "tsup": "^6.2.2", 55 | "typescript": "^4.4.4" 56 | }, 57 | "engines": { 58 | "node": ">= 14" 59 | }, 60 | "peerDependencies": { 61 | "regenerator-runtime": "^0.13.9" 62 | }, 63 | "peerDependenciesMeta": { 64 | "regenerator-runtime": { 65 | "optional": true 66 | } 67 | }, 68 | "resolutions": { 69 | "@babel/traverse": ">=7.23.2", 70 | "semver": ">=6.3.1", 71 | "tough-cookie": ">=4.1.3", 72 | "word-wrap": ">=1.2.4" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_HOST = 'https://pay.coinbase.com'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateOnRampURL } from './onramp/generateOnRampURL'; 2 | export { initOnRamp } from './onramp/initOnRamp'; 3 | export type { InitOnRampParams } from './onramp/initOnRamp'; 4 | export type { CBPayInstanceType } from './utils/CBPayInstance'; 5 | 6 | export type { MessageCode, MessageData, PostMessageData, SdkTarget } from './utils/postMessage'; 7 | export { CBPayInstance } from './utils/CBPayInstance'; 8 | export { onBroadcastedPostMessage, getSdkTarget, broadcastPostMessage } from './utils/postMessage'; 9 | export { broadcastEvent } from './utils/events'; 10 | -------------------------------------------------------------------------------- /src/offramp/generateOffRampURL.test.ts: -------------------------------------------------------------------------------- 1 | import { generateOffRampURL } from './generateOffRampURL'; 2 | 3 | describe('generateOffRampURL', () => { 4 | it('generates URL with expected default parameters', () => { 5 | const url = new URL( 6 | generateOffRampURL({ 7 | appId: 'test', 8 | }), 9 | ); 10 | 11 | expect(url.origin).toEqual('https://pay.coinbase.com'); 12 | expect(url.pathname).toEqual('/v3/sell/input'); 13 | expect(url.searchParams.get('appId')).toEqual('test'); 14 | }); 15 | 16 | it('should support redirectUrl', () => { 17 | const url = new URL( 18 | generateOffRampURL({ 19 | appId: 'test', 20 | redirectUrl: 'https://example.com', 21 | }), 22 | ); 23 | 24 | expect(url.searchParams.get('redirectUrl')).toEqual('https://example.com'); 25 | }); 26 | 27 | it('generates URL with multiple addresses', () => { 28 | const addresses = { 29 | '0x1': ['base', 'ethereum'], 30 | '123abc': ['solana'], 31 | }; 32 | 33 | const url = new URL( 34 | generateOffRampURL({ 35 | appId: 'test', 36 | addresses, 37 | redirectUrl: 'https://example.com', 38 | }), 39 | ); 40 | 41 | expect(url.searchParams.get('addresses')).toEqual( 42 | '{"0x1":["base","ethereum"],"123abc":["solana"]}', 43 | ); 44 | }); 45 | 46 | it('generates URL with multiple addresses and assets', () => { 47 | const url = new URL( 48 | generateOffRampURL({ 49 | appId: 'test', 50 | addresses: { 51 | '0x5ome4ddre55': ['ethereum', 'avalanche-c-chain'], 52 | '90123jd09ef09df': ['solana'], 53 | }, 54 | assets: ['USDC', 'SOL'], 55 | }), 56 | ); 57 | 58 | expect(url.searchParams.get('addresses')).toEqual( 59 | `{\"0x5ome4ddre55\":[\"ethereum\",\"avalanche-c-chain\"],\"90123jd09ef09df\":[\"solana\"]}`, 60 | ); 61 | expect(url.searchParams.get('assets')).toEqual('["USDC","SOL"]'); 62 | }); 63 | 64 | it('should support dynamic host', () => { 65 | const url = new URL( 66 | generateOffRampURL({ 67 | host: 'http://localhost:3000', 68 | appId: 'test', 69 | }), 70 | ); 71 | 72 | expect(url.origin).toEqual('http://localhost:3000'); 73 | expect(url.pathname).toEqual('/v3/sell/input'); 74 | expect(url.searchParams.get('appId')).toEqual('test'); 75 | }); 76 | 77 | it('should support preset amounts', () => { 78 | const url = new URL( 79 | generateOffRampURL({ 80 | appId: 'test', 81 | presetCryptoAmount: 0.1, 82 | presetFiatAmount: 20, 83 | }), 84 | ); 85 | 86 | expect(url.searchParams.get('presetFiatAmount')).toEqual('20'); 87 | expect(url.searchParams.get('presetCryptoAmount')).toEqual('0.1'); 88 | }); 89 | 90 | it('should support defaultNetwork', () => { 91 | const url = new URL( 92 | generateOffRampURL({ 93 | appId: 'test', 94 | defaultNetwork: 'ethereum', 95 | }), 96 | ); 97 | expect(url.searchParams.get('defaultNetwork')).toEqual('ethereum'); 98 | }); 99 | 100 | it('should support sessionToken', () => { 101 | const url = new URL( 102 | generateOffRampURL({ 103 | sessionToken: 'test', 104 | }), 105 | ); 106 | expect(url.origin).toEqual('https://pay.coinbase.com'); 107 | expect(url.pathname).toEqual('/v3/sell/input'); 108 | expect(url.searchParams.get('sessionToken')).toEqual('test'); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/offramp/generateOffRampURL.ts: -------------------------------------------------------------------------------- 1 | import { OffRampAppParams } from 'types/offramp'; 2 | import { DEFAULT_HOST } from '../config'; 3 | import type { Theme } from '../types/widget'; 4 | 5 | export type GenerateOffRampURLOptions = { 6 | /** This & addresses or sessionToken are required. */ 7 | appId?: string; 8 | host?: string; 9 | theme?: Theme; 10 | /** This or appId & addresses are required. */ 11 | sessionToken?: string; 12 | } & OffRampAppParams; 13 | 14 | export const generateOffRampURL = ({ 15 | host = DEFAULT_HOST, 16 | ...props 17 | }: GenerateOffRampURLOptions): string => { 18 | const url = new URL(host); 19 | url.pathname = '/v3/sell/input'; 20 | 21 | (Object.keys(props) as (keyof typeof props)[]).forEach((key) => { 22 | const value = props[key]; 23 | if (value !== undefined) { 24 | if (['string', 'number', 'boolean'].includes(typeof value)) { 25 | url.searchParams.append(key, value.toString()); 26 | } else { 27 | url.searchParams.append(key, JSON.stringify(value)); 28 | } 29 | } 30 | }); 31 | 32 | url.searchParams.sort(); 33 | 34 | return url.toString(); 35 | }; 36 | -------------------------------------------------------------------------------- /src/offramp/initOffRamp.test.ts: -------------------------------------------------------------------------------- 1 | import { initOffRamp } from './initOffRamp'; 2 | import { CBPayInstance } from '../utils/CBPayInstance'; 3 | 4 | jest.mock('../utils/CBPayInstance'); 5 | 6 | describe('initOffRamp', () => { 7 | it('should return CBPayInstance', async () => { 8 | let instance: unknown; 9 | initOffRamp( 10 | { 11 | experienceLoggedIn: 'popup', 12 | experienceLoggedOut: 'popup', 13 | appId: 'abc123', 14 | widgetParameters: { addresses: { '0x1': ['base'] }, redirectUrl: 'https://example.com' }, 15 | }, 16 | (_, newInstance) => { 17 | instance = newInstance; 18 | }, 19 | ); 20 | 21 | expect(CBPayInstance).toHaveBeenCalledTimes(1); 22 | 23 | expect(instance instanceof CBPayInstance).toBe(true); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/offramp/initOffRamp.ts: -------------------------------------------------------------------------------- 1 | import { CBPayExperienceOptions } from '../types/widget'; 2 | import { CBPayInstance, CBPayInstanceType } from '../utils/CBPayInstance'; 3 | import { OffRampAppParams } from '../types/offramp'; 4 | 5 | export type InitOffRampParams = CBPayExperienceOptions; 6 | 7 | export type InitOffRampCallback = { 8 | (error: Error, instance: null): void; 9 | (error: null, instance: CBPayInstanceType): void; 10 | }; 11 | 12 | export const initOffRamp = ( 13 | { 14 | experienceLoggedIn = 'new_tab', // default experience type 15 | widgetParameters, 16 | ...options 17 | }: InitOffRampParams, 18 | callback: InitOffRampCallback, 19 | ): void => { 20 | const instance = new CBPayInstance({ 21 | ...options, 22 | widget: 'sell', 23 | experienceLoggedIn, 24 | appParams: widgetParameters, 25 | }); 26 | callback(null, instance); 27 | }; 28 | -------------------------------------------------------------------------------- /src/onramp/generateOnRampURL.test.ts: -------------------------------------------------------------------------------- 1 | import { DestinationWallet } from '../types/onramp'; 2 | import { generateOnRampURL } from './generateOnRampURL'; 3 | 4 | describe('generateOnrampURL', () => { 5 | it('generates URL with expected default parameters', () => { 6 | const url = new URL( 7 | generateOnRampURL({ 8 | appId: 'test', 9 | destinationWallets: [], 10 | }), 11 | ); 12 | 13 | expect(url.origin).toEqual('https://pay.coinbase.com'); 14 | expect(url.pathname).toEqual('/buy/select-asset'); 15 | expect(url.searchParams.get('appId')).toEqual('test'); 16 | }); 17 | 18 | it('should support sessionToken', () => { 19 | const url = new URL( 20 | generateOnRampURL({ 21 | sessionToken: 'test', 22 | destinationWallets: [], 23 | }), 24 | ); 25 | expect(url.origin).toEqual('https://pay.coinbase.com'); 26 | expect(url.pathname).toEqual('/buy/select-asset'); 27 | expect(url.searchParams.get('sessionToken')).toEqual('test'); 28 | }); 29 | 30 | it('generates URL with empty destination wallets', () => { 31 | const url = new URL( 32 | generateOnRampURL({ 33 | appId: 'test', 34 | destinationWallets: [], 35 | }), 36 | ); 37 | 38 | expect(url.searchParams.get('destinationWallets')).toEqual('[]'); 39 | }); 40 | 41 | it('generates URL with multiple destination wallet configs', () => { 42 | const destinationWallets: DestinationWallet[] = [ 43 | { 44 | address: '0x5ome4ddre55', 45 | blockchains: ['ethereum', 'avalanche-c-chain'], 46 | assets: ['APE'], 47 | }, 48 | { 49 | address: '0x5ome4ddre55', 50 | assets: ['MATIC'], 51 | supportedNetworks: ['polygon'], 52 | }, 53 | { 54 | address: '90123jd09ef09df', 55 | blockchains: ['solana'], 56 | }, 57 | ]; 58 | 59 | const url = new URL( 60 | generateOnRampURL({ 61 | appId: 'test', 62 | destinationWallets, 63 | }), 64 | ); 65 | 66 | expect(url.searchParams.get('destinationWallets')).toEqual( 67 | `[{\"address\":\"0x5ome4ddre55\",\"blockchains\":[\"ethereum\",\"avalanche-c-chain\"],\"assets\":[\"APE\"]},{\"address\":\"0x5ome4ddre55\",\"assets\":[\"MATIC\"],\"supportedNetworks\":[\"polygon\"]},{\"address\":\"90123jd09ef09df\",\"blockchains\":[\"solana\"]}]`, 68 | ); 69 | }); 70 | 71 | it('generates URL with multiple addresses and assets', () => { 72 | const url = new URL( 73 | generateOnRampURL({ 74 | appId: 'test', 75 | addresses: { 76 | '0x5ome4ddre55': ['ethereum', 'avalanche-c-chain'], 77 | '90123jd09ef09df': ['solana'], 78 | }, 79 | assets: ['USDC', 'SOL'], 80 | }), 81 | ); 82 | 83 | expect(url.searchParams.get('addresses')).toEqual( 84 | `{\"0x5ome4ddre55\":[\"ethereum\",\"avalanche-c-chain\"],\"90123jd09ef09df\":[\"solana\"]}`, 85 | ); 86 | expect(url.searchParams.get('assets')).toEqual('["USDC","SOL"]'); 87 | }); 88 | 89 | it('fails when both destinationWallets and addresses are provided', () => { 90 | expect(() => 91 | generateOnRampURL({ 92 | appId: 'test', 93 | destinationWallets: [], 94 | addresses: {}, 95 | }), 96 | ).toThrowError(); 97 | }); 98 | 99 | it('should support dynamic host', () => { 100 | const url = new URL( 101 | generateOnRampURL({ 102 | host: 'http://localhost:3000', 103 | appId: 'test', 104 | destinationWallets: [], 105 | }), 106 | ); 107 | 108 | expect(url.origin).toEqual('http://localhost:3000'); 109 | expect(url.pathname).toEqual('/buy/select-asset'); 110 | expect(url.searchParams.get('appId')).toEqual('test'); 111 | }); 112 | 113 | it('should support preset amounts', () => { 114 | const url = new URL( 115 | generateOnRampURL({ 116 | appId: 'test', 117 | destinationWallets: [], 118 | presetCryptoAmount: 0.1, 119 | presetFiatAmount: 20, 120 | }), 121 | ); 122 | 123 | expect(url.searchParams.get('presetFiatAmount')).toEqual('20'); 124 | expect(url.searchParams.get('presetCryptoAmount')).toEqual('0.1'); 125 | }); 126 | 127 | it('should support defaultNetwork', () => { 128 | const url = new URL( 129 | generateOnRampURL({ 130 | appId: 'test', 131 | destinationWallets: [], 132 | defaultNetwork: 'polygon', 133 | }), 134 | ); 135 | expect(url.searchParams.get('defaultNetwork')).toEqual('polygon'); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/onramp/generateOnRampURL.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_HOST } from '../config'; 2 | import { OnRampAppParams } from '../types/onramp'; 3 | import type { Theme } from '../types/widget'; 4 | 5 | export type GenerateOnRampURLOptions = { 6 | /** This & destinationWallets or sessionToken are required. */ 7 | appId?: string; 8 | host?: string; 9 | /** This or appId & destinationWallets are required. */ 10 | sessionToken?: string; 11 | theme?: Theme; 12 | } & OnRampAppParams; 13 | 14 | export const generateOnRampURL = ({ 15 | host = DEFAULT_HOST, 16 | ...props 17 | }: GenerateOnRampURLOptions): string => { 18 | const url = new URL(host); 19 | url.pathname = '/buy/select-asset'; 20 | 21 | if (props.destinationWallets && props.addresses) { 22 | throw new Error('Only one of destinationWallets or addresses can be provided'); 23 | } else if (!props.destinationWallets && !props.addresses) { 24 | throw new Error('One of destinationWallets or addresses must be provided'); 25 | } 26 | 27 | (Object.keys(props) as (keyof typeof props)[]).forEach((key) => { 28 | const value = props[key]; 29 | if (value !== undefined) { 30 | if (['string', 'number', 'boolean'].includes(typeof value)) { 31 | url.searchParams.append(key, value.toString()); 32 | } else { 33 | url.searchParams.append(key, JSON.stringify(value)); 34 | } 35 | } 36 | }); 37 | 38 | url.searchParams.sort(); 39 | 40 | return url.toString(); 41 | }; 42 | -------------------------------------------------------------------------------- /src/onramp/initOnRamp.test.ts: -------------------------------------------------------------------------------- 1 | import { initOnRamp } from './initOnRamp'; 2 | import { CBPayInstance } from '../utils/CBPayInstance'; 3 | 4 | jest.mock('../utils/CBPayInstance'); 5 | 6 | describe('initOnRamp', () => { 7 | it('should return CBPayInstance', async () => { 8 | let instance: unknown; 9 | initOnRamp( 10 | { 11 | experienceLoggedIn: 'popup', 12 | experienceLoggedOut: 'popup', 13 | appId: 'abc123', 14 | widgetParameters: { destinationWallets: [] }, 15 | }, 16 | (_, newInstance) => { 17 | instance = newInstance; 18 | }, 19 | ); 20 | 21 | expect(CBPayInstance).toHaveBeenCalledTimes(1); 22 | 23 | expect(instance instanceof CBPayInstance).toBe(true); 24 | }); 25 | 26 | // TODO: More tests 27 | }); 28 | -------------------------------------------------------------------------------- /src/onramp/initOnRamp.ts: -------------------------------------------------------------------------------- 1 | import { CBPayExperienceOptions } from '../types/widget'; 2 | import { CBPayInstance, CBPayInstanceType } from '../utils/CBPayInstance'; 3 | import { OnRampAppParams } from '../types/onramp'; 4 | 5 | export type InitOnRampParams = CBPayExperienceOptions; 6 | 7 | export type InitOnRampCallback = { 8 | (error: Error, instance: null): void; 9 | (error: null, instance: CBPayInstanceType): void; 10 | }; 11 | 12 | export const initOnRamp = ( 13 | { 14 | experienceLoggedIn = 'new_tab', // default experience type 15 | widgetParameters, 16 | ...options 17 | }: InitOnRampParams, 18 | callback: InitOnRampCallback, 19 | ): void => { 20 | const instance = new CBPayInstance({ 21 | ...options, 22 | widget: 'buy', 23 | experienceLoggedIn, 24 | appParams: widgetParameters, 25 | }); 26 | callback(null, instance); 27 | }; 28 | -------------------------------------------------------------------------------- /src/types/JsonTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Matches a JSON object. 3 | This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`. 4 | @category JSON 5 | */ 6 | export type JsonObject = { [Key in string]?: JsonValue }; 7 | 8 | /** 9 | Matches a JSON array. 10 | @category JSON 11 | */ 12 | export type JsonArray = JsonValue[]; 13 | 14 | /** 15 | Matches any valid JSON primitive value. 16 | @category JSON 17 | */ 18 | export type JsonPrimitive = string | number | boolean | null; 19 | 20 | /** 21 | Matches any valid JSON value. 22 | @see `Jsonify` if you need to transform a type to one that is assignable to `JsonValue`. 23 | @category JSON 24 | */ 25 | export type JsonValue = JsonPrimitive | JsonObject | JsonArray; 26 | -------------------------------------------------------------------------------- /src/types/events.ts: -------------------------------------------------------------------------------- 1 | export type OpenEvent = { 2 | eventName: 'open'; 3 | widgetName: string; 4 | }; 5 | 6 | export type TransitionViewEvent = { 7 | eventName: 'transition_view'; 8 | pageRoute: string; 9 | }; 10 | 11 | export type PublicErrorEvent = { 12 | eventName: 'error'; 13 | // TODO: Public error shape 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | error: any; 16 | }; 17 | 18 | export type ExitEvent = { 19 | eventName: 'exit'; 20 | // TODO: Public error shape 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | error?: any; 23 | }; 24 | 25 | export type SuccessEvent = { 26 | eventName: 'success'; 27 | }; 28 | 29 | export type RequestOpenUrlEvent = { 30 | eventName: 'request_open_url'; 31 | url: string; 32 | }; 33 | 34 | export type EventMetadata = 35 | | OpenEvent 36 | | TransitionViewEvent 37 | | PublicErrorEvent 38 | | ExitEvent 39 | | SuccessEvent 40 | | RequestOpenUrlEvent; 41 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Window { 2 | // if the widget is rendered inside of react-native-webview, ReactNativeWebView will be defined; 3 | // if the has an onMessage prop, postMessage will be defined (see https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md#the-windowreactnativewebviewpostmessage-method-and-onmessage-prop); 4 | // so those are independently nullable 5 | ReactNativeWebView?: { postMessage?: (message: string) => void }; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/offramp.ts: -------------------------------------------------------------------------------- 1 | export type BaseOffRampAppParams = { 2 | /** 3 | * 4 | * Each entry in the record represents a wallet address and the networks it is valid for. There should only be a 5 | * single address for each network your app supports. Users will be able to cash out any owned assets supported by any of 6 | * the networks you specify. See the assets parameter if you want to restrict the available assets. 7 | * 8 | * Example: 9 | * 10 | * Show all assets users have on the base network, only on the base network: 11 | * 12 | * `{ "0x1": ["base"] }` 13 | * 14 | */ 15 | addresses?: Record; 16 | /** A URL that the user will be redirected to after to sign their transaction after the transaction has been committed. */ 17 | redirectUrl?: string; 18 | /** 19 | * This optional parameter will restrict the assets available for the user to cash out. It acts as a filter on the 20 | * networks specified in the {addresses} param. 21 | * 22 | * Example: 23 | * 24 | * Support only USDC on either the base network or the ethereum network: 25 | * 26 | * `addresses: { "0x1": ["base", "ethereum"] }, assets: ["USDC"]` 27 | * 28 | */ 29 | assets?: string[]; 30 | /** The default network that should be selected when multiple networks are present. */ 31 | defaultNetwork?: string; 32 | /** The preset input amount as a crypto value. i.e. 0.1 ETH. */ 33 | presetCryptoAmount?: number; 34 | /** 35 | * The preset input amount as a fiat value. i.e. 15 USD. 36 | * Ignored if presetCryptoAmount is also set. 37 | * Also note this only works for a subset of fiat currencies: USD, CAD, GBP, EUR 38 | * */ 39 | presetFiatAmount?: number; 40 | /** ID used to link all user transactions created during the session. */ 41 | partnerUserId?: string; 42 | }; 43 | 44 | export type OffRampAggregatorAppParams = { 45 | quoteId?: string; 46 | defaultAsset?: string; 47 | defaultNetwork?: string; 48 | defaultCashoutMethod?: string; // "CRYPTO_WALLET" | "FIAT_WALLET" | "CARD" | "ACH_BANK_ACCOUNT" | "PAYPAL" 49 | presetFiatAmount?: number; 50 | fiatCurrency?: string; 51 | disableEdit?: boolean; 52 | }; 53 | 54 | export type OffRampAppParams = 55 | | BaseOffRampAppParams 56 | | (BaseOffRampAppParams & OffRampAggregatorAppParams); 57 | -------------------------------------------------------------------------------- /src/types/onramp.ts: -------------------------------------------------------------------------------- 1 | export type DestinationWallet = { 2 | /** 3 | * Destination address where the purchased assets will be sent for the assets/networks listed in the other fields. 4 | */ 5 | address: string; 6 | /** 7 | * List of networks enabled for the associated address. For any networks in this field, the user will be able to 8 | * buy/send any asset that is supported on this network. 9 | * 10 | * If you only want to support a subset of assets, leave this empty and use the assets field instead. 11 | */ 12 | blockchains?: string[]; 13 | /** 14 | * List of assets enabled for the associated address. If blockchains is non-empty, these assets will be available in 15 | * addition to all assets supported by the networks in the blockchains field. If blockchains is empty, only these 16 | * asset will be available. 17 | */ 18 | assets?: string[]; 19 | /** 20 | * Restrict the networks available for assets in the assets field. If the blockchains field is empty, only these 21 | * networks will be available. Otherwise these networks will be available in addition to the networks in blockchains. 22 | */ 23 | supportedNetworks?: string[]; 24 | }; 25 | 26 | export type OnRampExperience = 'buy' | 'send'; 27 | 28 | type BaseOnRampAppParams = { 29 | /** 30 | * @deprecated Please use the addresses and assets params instead. This parameter will be removed in a future release. 31 | * 32 | * This parameter controls which crypto assets your user will be able to buy/send, which wallet address their asset 33 | * will be delivered to, and which networks their assets will be delivered on. If this parameter is not provided, the 34 | * {addresses} param must be provided. 35 | * 36 | * Some common examples: 37 | * 38 | * Support all assets that are available for sending on the base network, only on the base network: 39 | * 40 | * `[{ address: "0x1", blockchains: ["base"] }]` 41 | * 42 | * Support only USDC on either the base network or the ethereum network: 43 | * 44 | * `[{ address: "0x1", assets: ["USDC"], supportedNetworks: ["base", "ethereum"] }]` 45 | * 46 | */ 47 | destinationWallets?: DestinationWallet[]; 48 | /** 49 | * The addresses parameter is a simpler way to provide the addresses customers funds should be delivered to. One of 50 | * either {addresses} or {destinationWallets} must be provided. 51 | * 52 | * Each entry in the record represents a wallet address and the networks it is valid for. There should only be a 53 | * single address for each network your app supports. Users will be able to buy/send any asset supported by any of 54 | * the networks you specify. See the assets param if you want to restrict the avaialable assets. 55 | * 56 | * Some common examples: 57 | * 58 | * Support all assets that are available for sending on the base network, only on the base network: 59 | * 60 | * `{ "0x1": ["base"] }` 61 | */ 62 | addresses?: Record; 63 | /** 64 | * This optional parameter will restrict the assets available for the user to buy/send. It acts as a filter on the 65 | * networks specified in the {addresses} param. 66 | * 67 | * Some common examples: 68 | * 69 | * Support only USDC on either the base network or the ethereum network: 70 | * 71 | * `addresses: { "0x1": ["base", "ethereum"] }, assets: ["USDC"]` 72 | * 73 | * The values in this list can either be asset symbols like BTC, ETH, or asset UUIDs that you can get from the Buy 74 | * Options API {@link https://docs.cdp.coinbase.com/onramp/docs/api-configurations/#buy-options}. 75 | */ 76 | assets?: string[]; 77 | /** The preset input amount as a crypto value. i.e. 0.1 ETH. This will be the initial default for all cryptocurrencies. */ 78 | presetCryptoAmount?: number; 79 | /** 80 | * The preset input amount as a fiat value. i.e. 15 USD. 81 | * This will be the initial default for all cryptocurrencies. Ignored if presetCryptoAmount is also set. 82 | * Also note this only works for a subset of fiat currencies: USD, CAD, GBP, EUR 83 | * */ 84 | presetFiatAmount?: number; 85 | /** The default network that should be selected when multiple networks are present. */ 86 | defaultNetwork?: string; 87 | /** The default experience the user should see: either transfer funds from Coinbase (`'send'`) or buy them (`'buy'`). */ 88 | defaultExperience?: OnRampExperience; 89 | handlingRequestedUrls?: boolean; 90 | /** ID used to link all user transactions created during the session. */ 91 | partnerUserId?: string; 92 | /** A URL that the user will be automatically redirected to after a successful buy/send. The domain must match a domain 93 | * on the domain allowlist in Coinbase Developer Platform (https://portal.cdp.coinbase.com/products/onramp). */ 94 | redirectUrl?: string; 95 | }; 96 | 97 | export type OnRampAggregatorAppParams = { 98 | quoteId: string; 99 | defaultAsset: string; 100 | defaultNetwork?: string; 101 | defaultPaymentMethod: string; 102 | presetFiatAmount: number; 103 | fiatCurrency: string; 104 | }; 105 | 106 | export type OnRampAppParams = 107 | | BaseOnRampAppParams 108 | | (BaseOnRampAppParams & OnRampAggregatorAppParams); 109 | -------------------------------------------------------------------------------- /src/types/widget.ts: -------------------------------------------------------------------------------- 1 | import { EventMetadata } from './events'; 2 | 3 | export type WidgetType = 'buy' | 'checkout' | 'sell'; 4 | 5 | export type IntegrationType = 'direct' | 'secure_standalone'; 6 | 7 | /** 8 | * Note: Two factor authentication does not work in an iframe, so the embedded experience should not be used. It will 9 | * be removed in a future release. 10 | */ 11 | export type Experience = 'embedded' | 'popup' | 'new_tab'; 12 | 13 | export type Theme = 'light' | 'dark'; 14 | 15 | export type EmbeddedContentStyles = { 16 | target?: string; 17 | width?: string; 18 | height?: string; 19 | position?: string; 20 | top?: string; 21 | }; 22 | 23 | export type CBPayExperienceOptions = { 24 | widgetParameters: T; 25 | target?: string; 26 | appId: string; 27 | host?: string; 28 | debug?: boolean; 29 | theme?: Theme; 30 | onExit?: (error?: Error) => void; 31 | onSuccess?: () => void; 32 | onEvent?: (event: EventMetadata) => void; 33 | onRequestedUrl?: (url: string) => void; 34 | closeOnExit?: boolean; 35 | closeOnSuccess?: boolean; 36 | embeddedContentStyles?: EmbeddedContentStyles; 37 | experienceLoggedIn?: Experience; 38 | experienceLoggedOut?: Experience; 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/CBPayInstance.test.ts: -------------------------------------------------------------------------------- 1 | import { CBPayInstance } from './CBPayInstance'; 2 | 3 | describe('CBPayInstance', () => { 4 | it('creating CBPayInstance instance should be ok', () => { 5 | new CBPayInstance(DEFAULT_ARGS); 6 | }); 7 | 8 | // TODO: Plenty of more tests we _should_ add here (regarding events/messaging/etc) 9 | }); 10 | 11 | const DEFAULT_ARGS: ConstructorParameters[0] = { 12 | appId: 'abc123', 13 | appParams: {}, 14 | widget: 'buy', 15 | experienceLoggedIn: 'popup', 16 | experienceLoggedOut: 'popup', 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/CBPayInstance.ts: -------------------------------------------------------------------------------- 1 | import { JsonObject } from 'types/JsonTypes'; 2 | import { CBPayExperienceOptions, Experience, WidgetType } from 'types/widget'; 3 | import { CoinbasePixel } from './CoinbasePixel'; 4 | 5 | export type InternalExperienceOptions = Omit< 6 | CBPayExperienceOptions, 7 | 'widgetParameters' 8 | > & { 9 | widget: WidgetType; 10 | experienceLoggedIn: Experience; // Required 11 | }; 12 | 13 | export type CBPayInstanceConstructorArguments = { 14 | appParams: JsonObject; 15 | } & InternalExperienceOptions; 16 | 17 | const widgetRoutes: Record = { 18 | buy: '/buy', 19 | checkout: '/checkout', 20 | sell: '/v3/sell', 21 | }; 22 | 23 | export interface CBPayInstanceType { 24 | open: () => void; 25 | destroy: () => void; 26 | } 27 | 28 | export class CBPayInstance implements CBPayInstanceType { 29 | private pixel: CoinbasePixel; 30 | private options: InternalExperienceOptions; 31 | 32 | constructor(options: CBPayInstanceConstructorArguments) { 33 | this.options = options; 34 | this.pixel = new CoinbasePixel({ 35 | ...options, 36 | appParams: { 37 | widget: options.widget, 38 | ...options.appParams, 39 | }, 40 | }); 41 | 42 | if (options.target) { 43 | const targetElement = document.querySelector(options.target); 44 | if (targetElement) { 45 | targetElement.addEventListener('click', this.open); 46 | } 47 | } 48 | } 49 | 50 | public open = (): void => { 51 | const { 52 | widget, 53 | experienceLoggedIn, 54 | experienceLoggedOut, 55 | embeddedContentStyles, 56 | onExit, 57 | onSuccess, 58 | onEvent, 59 | onRequestedUrl, 60 | closeOnSuccess, 61 | closeOnExit, 62 | } = this.options; 63 | 64 | this.pixel.openExperience({ 65 | path: widgetRoutes[widget], 66 | experienceLoggedIn, 67 | experienceLoggedOut, 68 | embeddedContentStyles, 69 | onExit: () => { 70 | onExit?.(); 71 | if (closeOnExit) { 72 | this.pixel.endExperience(); 73 | } 74 | }, 75 | onSuccess: () => { 76 | onSuccess?.(); 77 | if (closeOnSuccess) { 78 | this.pixel.endExperience(); 79 | } 80 | }, 81 | onRequestedUrl, 82 | onEvent, 83 | }); 84 | }; 85 | 86 | public destroy = (): void => { 87 | this.pixel.destroy(); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/CoinbasePixel.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CoinbasePixel, 3 | CoinbasePixelConstructorParams, 4 | OpenExperienceOptions, 5 | } from './CoinbasePixel'; 6 | import { EMBEDDED_IFRAME_ID } from './createEmbeddedContent'; 7 | 8 | import { onBroadcastedPostMessage } from './postMessage'; 9 | 10 | jest.mock('./postMessage', () => ({ 11 | onBroadcastedPostMessage: jest.fn(), 12 | broadcastPostMessage: jest.fn(), 13 | })); 14 | 15 | describe('CoinbasePixel', () => { 16 | window.open = jest.fn(); 17 | 18 | let mockUnsubCallback: jest.Mock; 19 | let defaultArgs: CoinbasePixelConstructorParams; 20 | const defaultAppParams = { 21 | addresses: { '0x0': ['ethereum'] }, 22 | }; 23 | const defaultOpenOptions: OpenExperienceOptions = { 24 | path: '/buy', 25 | experienceLoggedIn: 'embedded', 26 | }; 27 | 28 | beforeEach(() => { 29 | mockUnsubCallback = jest.fn(); 30 | (onBroadcastedPostMessage as jest.Mock).mockReturnValue(mockUnsubCallback); 31 | defaultArgs = { 32 | appId: 'test', 33 | appParams: defaultAppParams, 34 | }; 35 | }); 36 | 37 | afterEach(() => { 38 | document.getElementById(EMBEDDED_IFRAME_ID)?.remove(); 39 | // @ts-expect-error - test 40 | window.chrome = undefined; 41 | jest.resetAllMocks(); 42 | }); 43 | 44 | it('should initialize with default values', () => { 45 | const instance = createUntypedPixel(defaultArgs); 46 | 47 | expect(instance.appId).toEqual('test'); 48 | expect(instance.host).toEqual('https://pay.coinbase.com'); 49 | expect(instance.unsubs.length).toEqual(0); 50 | expect(instance.appParams).toEqual(defaultArgs.appParams); 51 | }); 52 | 53 | it('should handle opening the embedded experience when logged out', () => { 54 | const instance = createUntypedPixel(defaultArgs); 55 | 56 | instance.openExperience(defaultOpenOptions); 57 | 58 | expect(window.open).toHaveBeenCalledWith( 59 | 'https://pay.coinbase.com/signin?appId=test&type=direct', 60 | 'Coinbase', 61 | 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=730,width=460', 62 | ); 63 | expect(findMockedListeners('signin_success')).toHaveLength(1); 64 | }); 65 | 66 | it('should handle opening the popup experience in chrome extensions', () => { 67 | window.chrome = { 68 | // @ts-expect-error - test 69 | windows: { 70 | create: jest.fn(), 71 | }, 72 | }; 73 | 74 | const instance = new CoinbasePixel(defaultArgs); 75 | 76 | instance.openExperience({ ...defaultOpenOptions, experienceLoggedIn: 'popup' }); 77 | 78 | expect(window.chrome.windows.create).toHaveBeenCalledWith( 79 | { 80 | focused: true, 81 | height: 730, 82 | left: -470, 83 | setSelfAsOpener: true, 84 | top: 0, 85 | type: 'popup', 86 | url: 'https://pay.coinbase.com/buy/select-asset?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 87 | width: 460, 88 | }, 89 | expect.any(Function), 90 | ); 91 | }); 92 | 93 | it('should handle opening the new_tab experience in chrome extensions', () => { 94 | window.chrome = { 95 | // @ts-expect-error - test 96 | tabs: { 97 | create: jest.fn(), 98 | }, 99 | }; 100 | 101 | const instance = new CoinbasePixel(defaultArgs); 102 | 103 | instance.openExperience({ ...defaultOpenOptions, experienceLoggedIn: 'new_tab' }); 104 | 105 | expect(window.chrome.tabs.create).toHaveBeenCalledWith({ 106 | url: 'https://pay.coinbase.com/buy/select-asset?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 107 | }); 108 | }); 109 | 110 | it('should handle opening offramp the new_tab experience in chrome extensions', () => { 111 | window.chrome = { 112 | // @ts-expect-error - test 113 | tabs: { 114 | create: jest.fn(), 115 | }, 116 | }; 117 | 118 | const instance = new CoinbasePixel(defaultArgs); 119 | 120 | instance.openExperience({ 121 | ...defaultOpenOptions, 122 | experienceLoggedIn: 'new_tab', 123 | path: '/v3/sell', 124 | }); 125 | 126 | expect(window.chrome.tabs.create).toHaveBeenCalledWith({ 127 | url: 'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 128 | }); 129 | }); 130 | 131 | it('should handle opening the popup experience in browsers', () => { 132 | const instance = new CoinbasePixel(defaultArgs); 133 | 134 | instance.openExperience({ ...defaultOpenOptions, experienceLoggedIn: 'popup' }); 135 | 136 | expect(window.open).toHaveBeenCalledWith( 137 | 'https://pay.coinbase.com/buy/select-asset?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 138 | 'Coinbase', 139 | 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=730,width=460', 140 | ); 141 | }); 142 | 143 | it('should handle opening offramp in the popup experience in browsers', () => { 144 | const instance = new CoinbasePixel(defaultArgs); 145 | 146 | instance.openExperience({ 147 | ...defaultOpenOptions, 148 | experienceLoggedIn: 'popup', 149 | path: '/v3/sell', 150 | }); 151 | 152 | expect(window.open).toHaveBeenCalledWith( 153 | 'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 154 | 'Coinbase', 155 | 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=730,width=460', 156 | ); 157 | }); 158 | 159 | it('should handle opening the new_tab experience in browsers', () => { 160 | const instance = new CoinbasePixel(defaultArgs); 161 | 162 | instance.openExperience({ ...defaultOpenOptions, experienceLoggedIn: 'new_tab' }); 163 | 164 | expect(window.open).toHaveBeenCalledWith( 165 | 'https://pay.coinbase.com/buy/select-asset?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 166 | 'Coinbase', 167 | undefined, 168 | ); 169 | }); 170 | 171 | it('should handle opening the offramp experience in new_tab in browsers', () => { 172 | const instance = createUntypedPixel(defaultArgs); 173 | 174 | instance.openExperience({ 175 | ...defaultOpenOptions, 176 | experienceLoggedIn: 'new_tab', 177 | path: '/v3/sell', 178 | }); 179 | 180 | expect(window.open).toHaveBeenCalledWith( 181 | 'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test', 182 | 'Coinbase', 183 | undefined, 184 | ); 185 | }); 186 | 187 | it('.destroy should remove embedded pixel', () => { 188 | const instance = createUntypedPixel(defaultArgs); 189 | expect(instance.unsubs).toHaveLength(0); 190 | 191 | instance.openExperience(defaultOpenOptions); 192 | expect(instance.unsubs).toHaveLength(2); 193 | 194 | instance.destroy(); 195 | expect(mockUnsubCallback).toHaveBeenCalledTimes(2); 196 | }); 197 | }); 198 | 199 | // Used to assert private properties without type errors 200 | function createUntypedPixel(options: CoinbasePixelConstructorParams) { 201 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 202 | return new CoinbasePixel(options) as any; 203 | } 204 | 205 | function findMockedListeners(message: string) { 206 | return (onBroadcastedPostMessage as jest.Mock).mock.calls.filter(([m]) => m === message); 207 | } 208 | -------------------------------------------------------------------------------- /src/utils/CoinbasePixel.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_HOST } from '../config'; 2 | import { EmbeddedContentStyles, Experience, Theme } from 'types/widget'; 3 | import { createEmbeddedContent, EMBEDDED_IFRAME_ID } from './createEmbeddedContent'; 4 | import { JsonObject } from 'types/JsonTypes'; 5 | import { onBroadcastedPostMessage } from './postMessage'; 6 | import { EventMetadata } from 'types/events'; 7 | import { generateOnRampURL } from '../onramp/generateOnRampURL'; 8 | import { generateOffRampURL } from '../offramp/generateOffRampURL'; 9 | 10 | const PopupSizes: Record<'signin' | 'widget', { width: number; height: number }> = { 11 | signin: { 12 | width: 460, 13 | height: 730, 14 | }, 15 | widget: { 16 | width: 430, 17 | height: 600, 18 | }, 19 | }; 20 | 21 | export type ExperienceListeners = { 22 | onExit?: (data?: JsonObject) => void; 23 | onSuccess?: (data?: JsonObject) => void; 24 | onEvent?: (event: EventMetadata) => void; 25 | onRequestedUrl?: (url: string) => void; 26 | }; 27 | 28 | export type CoinbasePixelConstructorParams = { 29 | host?: string; 30 | appId: string; 31 | appParams: JsonObject; 32 | debug?: boolean; 33 | theme?: Theme; 34 | }; 35 | 36 | export type OpenExperienceOptions = { 37 | path: string; 38 | experienceLoggedIn: Experience; 39 | experienceLoggedOut?: Experience; 40 | embeddedContentStyles?: EmbeddedContentStyles; 41 | } & ExperienceListeners; 42 | 43 | export class CoinbasePixel { 44 | private debug: boolean; 45 | private host: string; 46 | private appId: string; 47 | private eventStreamListeners: Partial void)[]>> = {}; 48 | private unsubs: (() => void)[] = []; 49 | private appParams: JsonObject; 50 | private removeEventListener?: () => void; 51 | private theme: Theme | null | undefined; 52 | 53 | constructor({ 54 | host = DEFAULT_HOST, 55 | appId, 56 | appParams, 57 | debug, 58 | theme, 59 | }: CoinbasePixelConstructorParams) { 60 | this.host = host; 61 | this.appId = appId; 62 | this.appParams = appParams; 63 | this.debug = debug || false; 64 | this.theme = theme; 65 | } 66 | 67 | /** Opens the CB Pay experience */ 68 | public openExperience = (options: OpenExperienceOptions): void => { 69 | this.log('Attempting to open experience'); 70 | 71 | this.setupExperienceListeners(options); 72 | 73 | const { experienceLoggedIn, experienceLoggedOut, embeddedContentStyles } = options; 74 | 75 | const experience = experienceLoggedOut || experienceLoggedIn; 76 | 77 | let url = ''; 78 | if (options.path === '/v3/sell') { 79 | url = generateOffRampURL({ 80 | appId: this.appId, 81 | host: this.host, 82 | theme: this.theme ?? undefined, 83 | ...this.appParams, 84 | }); 85 | } else { 86 | url = generateOnRampURL({ 87 | appId: this.appId, 88 | host: this.host, 89 | theme: this.theme ?? undefined, 90 | ...this.appParams, 91 | }); 92 | } 93 | 94 | this.log('Opening experience', { experience }); 95 | 96 | if (experience === 'embedded') { 97 | this.log( 98 | 'DEPRECATION WARNING: Two factor authentication does not work in an iframe, so the embedded experience should not be used. It will be removed in a future release', 99 | ); 100 | const openEmbeddedExperience = () => { 101 | const embedded = createEmbeddedContent({ url, ...embeddedContentStyles }); 102 | if (embeddedContentStyles?.target) { 103 | document.querySelector(embeddedContentStyles?.target)?.replaceChildren(embedded); 104 | } else { 105 | document.body.appendChild(embedded); 106 | } 107 | }; 108 | 109 | this.startDirectSignin(openEmbeddedExperience); 110 | } else if (experience === 'popup' && window.chrome?.windows?.create) { 111 | void window.chrome.windows.create( 112 | { 113 | url, 114 | setSelfAsOpener: true, 115 | type: 'popup', 116 | focused: true, 117 | width: PopupSizes.signin.width, 118 | height: PopupSizes.signin.height, 119 | left: window.screenLeft - PopupSizes.signin.width - 10, 120 | top: window.screenTop, 121 | }, 122 | (winRef) => { 123 | const onOpenCallback = () => { 124 | if (winRef?.id) { 125 | chrome.windows.update(winRef.id, { 126 | width: PopupSizes.widget.width, 127 | height: PopupSizes.widget.height, 128 | left: window.screenLeft - PopupSizes.widget.width - 10, 129 | top: window.screenTop, 130 | }); 131 | this.removeEventStreamListener('open', onOpenCallback); 132 | } 133 | }; 134 | this.addEventStreamListener('open', onOpenCallback); 135 | }, 136 | ); 137 | } else if (experience === 'new_tab' && window.chrome?.tabs?.create) { 138 | void window.chrome.tabs.create({ url }); 139 | } else { 140 | openWindow(url, experience); 141 | } 142 | }; 143 | 144 | public endExperience = (): void => { 145 | document.getElementById(EMBEDDED_IFRAME_ID)?.remove(); 146 | }; 147 | 148 | public destroy = (): void => { 149 | this.unsubs.forEach((unsub) => unsub()); 150 | }; 151 | 152 | private setupExperienceListeners = ({ 153 | onSuccess, 154 | onExit, 155 | onEvent, 156 | onRequestedUrl, 157 | }: ExperienceListeners) => { 158 | // Unsubscribe from events in case there's still an active listener 159 | if (this.removeEventListener) { 160 | this.removeEventListener(); 161 | } 162 | 163 | this.removeEventListener = this.onMessage('event', { 164 | shouldUnsubscribe: false, 165 | onMessage: (data) => { 166 | const metadata = data as EventMetadata; 167 | 168 | this.eventStreamListeners[metadata.eventName]?.forEach((cb) => cb?.()); 169 | 170 | if (metadata.eventName === 'success') { 171 | onSuccess?.(); 172 | } 173 | if (metadata.eventName === 'exit') { 174 | onExit?.(metadata.error); 175 | } 176 | if (metadata.eventName === 'request_open_url') { 177 | onRequestedUrl?.(metadata.url); 178 | } 179 | onEvent?.(data as EventMetadata); 180 | }, 181 | }); 182 | }; 183 | 184 | private startDirectSignin = (callback: () => void) => { 185 | const queryParams = new URLSearchParams(); 186 | queryParams.set('appId', this.appId); 187 | queryParams.set('type', 'direct'); 188 | const directSigninUrl = `${this.host}/signin?${queryParams.toString()}`; 189 | const signinWinRef = openWindow(directSigninUrl, 'popup'); 190 | 191 | this.onMessage('signin_success', { 192 | onMessage: () => { 193 | signinWinRef?.close(); 194 | callback(); 195 | }, 196 | }); 197 | }; 198 | 199 | private addEventStreamListener = (name: EventMetadata['eventName'], cb: () => void) => { 200 | if (this.eventStreamListeners[name]) { 201 | this.eventStreamListeners[name]?.push(cb); 202 | } else { 203 | this.eventStreamListeners[name] = [cb]; 204 | } 205 | }; 206 | 207 | private removeEventStreamListener = (name: EventMetadata['eventName'], callback: () => void) => { 208 | if (this.eventStreamListeners[name]) { 209 | const filteredListeners = this.eventStreamListeners[name]?.filter((cb) => cb !== callback); 210 | this.eventStreamListeners[name] = filteredListeners; 211 | } 212 | }; 213 | 214 | private onMessage = (...args: Parameters) => { 215 | const unsubFxn = onBroadcastedPostMessage(args[0], { allowedOrigin: this.host, ...args[1] }); 216 | this.unsubs.push(unsubFxn); 217 | 218 | return unsubFxn; 219 | }; 220 | 221 | private log = (...args: Parameters) => { 222 | if (this.debug) { 223 | console.log('[CBPAY]', ...args); 224 | } 225 | }; 226 | } 227 | 228 | function openWindow(url: string, experience: Experience) { 229 | return window.open( 230 | url, 231 | 'Coinbase', 232 | experience === 'popup' 233 | ? `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=${PopupSizes.signin.height},width=${PopupSizes.signin.width}` 234 | : undefined, 235 | ); 236 | } 237 | -------------------------------------------------------------------------------- /src/utils/createEmbeddedContent.test.ts: -------------------------------------------------------------------------------- 1 | import { createEmbeddedContent, EMBEDDED_IFRAME_ID } from './createEmbeddedContent'; 2 | 3 | describe('createEmbeddedContent', () => { 4 | it('creates iframe with given URL', () => { 5 | const iframe = createEmbeddedContent({ url: TEST_URL }); 6 | expect(iframe.src).toMatch(TEST_URL); 7 | }); 8 | 9 | it('gives the iframe a fixed ID', () => { 10 | const iframe = createEmbeddedContent({ url: TEST_URL }); 11 | expect(iframe.id).toBe(EMBEDDED_IFRAME_ID); 12 | }); 13 | 14 | it('should have default styles (full screen)', () => { 15 | const iframe = createEmbeddedContent({ url: TEST_URL }); 16 | 17 | const { width, height, position, top, border, borderWidth } = iframe.style; 18 | expect(width).toBe('100%'); 19 | expect(height).toBe('100%'); 20 | expect(position).toBe('fixed'); 21 | expect(top).toBe('0px'); 22 | expect(border).toBe('0px'); 23 | expect(borderWidth).toBe('0px'); 24 | }); 25 | 26 | it('allows overriding of some key style properties', () => { 27 | const iframe = createEmbeddedContent({ 28 | url: TEST_URL, 29 | width: '50px', 30 | height: '30vh', 31 | position: 'absolute', 32 | top: '18px', 33 | }); 34 | 35 | const { width, height, position, top, border, borderWidth } = iframe.style; 36 | expect(width).toBe('50px'); 37 | expect(height).toBe('30vh'); 38 | expect(position).toBe('absolute'); 39 | expect(top).toBe('18px'); 40 | // Border should not be changed 41 | expect(border).toBe('0px'); 42 | expect(borderWidth).toBe('0px'); 43 | }); 44 | }); 45 | 46 | const TEST_URL = 'https://foo.bar'; 47 | -------------------------------------------------------------------------------- /src/utils/createEmbeddedContent.ts: -------------------------------------------------------------------------------- 1 | import { EmbeddedContentStyles } from 'types/widget'; 2 | 3 | export const EMBEDDED_IFRAME_ID = 'cbpay-embedded-onramp'; 4 | 5 | export const createEmbeddedContent = ({ 6 | url, 7 | width = '100%', 8 | height = '100%', 9 | position = 'fixed', 10 | top = '0px', 11 | }: { 12 | url: string; 13 | } & EmbeddedContentStyles): HTMLIFrameElement => { 14 | const iframe = document.createElement('iframe'); 15 | 16 | // Styles 17 | iframe.style.border = 'unset'; 18 | iframe.style.borderWidth = '0'; 19 | iframe.style.width = width.toString(); 20 | iframe.style.height = height.toString(); 21 | iframe.style.position = position; 22 | iframe.style.top = top; 23 | iframe.id = EMBEDDED_IFRAME_ID; 24 | iframe.src = url; 25 | 26 | return iframe; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { EventMetadata } from 'types/events'; 2 | import { broadcastPostMessage, SdkTarget } from './postMessage'; 3 | 4 | export function broadcastEvent(sdkTarget: SdkTarget, event: EventMetadata): void { 5 | broadcastPostMessage(sdkTarget, 'event', { 6 | data: event, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/postMessage.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { onBroadcastedPostMessage, broadcastPostMessage, getSdkTarget } from './postMessage'; 5 | 6 | const flushMessages = () => new Promise((resolve) => setTimeout(resolve, 10)); 7 | 8 | const domain = 'https://coinbase.com'; 9 | 10 | const patchOriginEvent = (event: MessageEvent) => { 11 | if (event.origin === '') { 12 | event.stopImmediatePropagation(); 13 | const eventWithOrigin = new MessageEvent('message', { 14 | data: event.data, 15 | origin: domain, 16 | }); 17 | window.dispatchEvent(eventWithOrigin); 18 | } 19 | }; 20 | 21 | describe('postMessage', () => { 22 | beforeAll(() => { 23 | window.addEventListener('message', patchOriginEvent); 24 | }); 25 | 26 | afterAll(() => { 27 | window.removeEventListener('message', patchOriginEvent); 28 | }); 29 | 30 | describe('onBroadcastedPostMessage', () => { 31 | it('triggers callback on message', async () => { 32 | const callbackMock = jest.fn(); 33 | onBroadcastedPostMessage('pixel_ready', { onMessage: callbackMock }); 34 | 35 | window.postMessage('pixel_ready', '*'); 36 | 37 | await flushMessages(); 38 | 39 | expect(callbackMock).toHaveBeenCalled(); 40 | }); 41 | 42 | it.each([ 43 | ['https://coinbase.com', true], 44 | ['https://bad-website.com', false], 45 | ])('validates origin for %s', async (allowedOrigin, isCallbackExpected) => { 46 | const callbackMock = jest.fn(); 47 | const onValidateOriginMock = jest.fn(async (origin) => origin === allowedOrigin); 48 | onBroadcastedPostMessage('pixel_ready', { 49 | onMessage: callbackMock, 50 | onValidateOrigin: onValidateOriginMock, 51 | }); 52 | 53 | window.postMessage('pixel_ready', '*'); 54 | 55 | await flushMessages(); 56 | 57 | expect(onValidateOriginMock).toHaveBeenCalled(); 58 | expect(await onValidateOriginMock.mock.results[0].value).toEqual(isCallbackExpected); 59 | expect(callbackMock).toHaveBeenCalledTimes(isCallbackExpected ? 1 : 0); 60 | }); 61 | 62 | it.each([ 63 | ['https://coinbase.com', true], 64 | ['https://bad-website.com', false], 65 | ])('triggers callback for allowedOrigin for %s', async (allowedOrigin, isCallbackExpected) => { 66 | const callbackMock = jest.fn(); 67 | onBroadcastedPostMessage('pixel_ready', { 68 | onMessage: callbackMock, 69 | allowedOrigin, 70 | }); 71 | 72 | window.postMessage('pixel_ready', '*'); 73 | 74 | await flushMessages(); 75 | 76 | expect(callbackMock).toHaveBeenCalledTimes(isCallbackExpected ? 1 : 0); 77 | }); 78 | }); 79 | 80 | describe('broadcastPostMessage', () => { 81 | let onMessageMock = jest.fn(); 82 | 83 | const onMessage = (e: MessageEvent) => { 84 | onMessageMock({ data: e.data, origin: e.origin }); 85 | }; 86 | 87 | beforeEach(() => { 88 | onMessageMock = jest.fn(); 89 | window.addEventListener('message', onMessage); 90 | }); 91 | 92 | afterEach(() => { 93 | window.removeEventListener('message', onMessage); 94 | }); 95 | 96 | it('sends post message', async () => { 97 | broadcastPostMessage(window, 'pixel_ready'); 98 | 99 | await flushMessages(); 100 | 101 | expect(onMessageMock).toBeCalledWith({ 102 | data: 'pixel_ready', 103 | origin: 'https://coinbase.com', 104 | }); 105 | }); 106 | 107 | it('sends formats data correctly', async () => { 108 | broadcastPostMessage(window, 'pixel_ready', { data: { test: 'hi' } }); 109 | 110 | await flushMessages(); 111 | 112 | expect(onMessageMock).toBeCalledWith( 113 | expect.objectContaining({ 114 | data: '{"eventName":"pixel_ready","data":{"test":"hi"}}', 115 | }), 116 | ); 117 | }); 118 | }); 119 | 120 | describe('getSdkTarget', () => { 121 | type RNWindow = Window & typeof globalThis & { ReactNativeWebView: unknown }; 122 | 123 | const originalOpener = window.opener; 124 | const originalParent = Object.getOwnPropertyDescriptor(window, 'parent') ?? {}; 125 | 126 | afterEach(() => { 127 | window.opener = originalOpener; 128 | Object.defineProperty(window, 'parent', originalParent); 129 | delete (window as RNWindow).ReactNativeWebView; 130 | }); 131 | 132 | it("returns the widget's window when called from the SDK internally", () => { 133 | const otherWin = {} as Window; // TODO: Get a different window object somehow? 134 | const target = getSdkTarget(otherWin); 135 | 136 | expect(target).toBe(otherWin); 137 | }); 138 | 139 | it('else returns a postMessage object for RN (Mobile SDK)', () => { 140 | const ReactNativeWebView = { 141 | postMessage: jest.fn(), 142 | }; 143 | (window as RNWindow).ReactNativeWebView = ReactNativeWebView; 144 | const target = getSdkTarget(window); 145 | target?.postMessage('test', ''); 146 | 147 | expect(ReactNativeWebView.postMessage).toHaveBeenCalledTimes(1); 148 | }); 149 | 150 | it("else returns the window's opener (Button Proxy) ", () => { 151 | const opener = {} as unknown as Window; 152 | window.opener = opener; 153 | const target = getSdkTarget(window); 154 | 155 | expect(target).toBe(opener); 156 | }); 157 | 158 | it('else returns parent window (Third party / SDK)', () => { 159 | const parent = {} as unknown as Window; 160 | Object.defineProperty(window, 'parent', { 161 | get: () => parent, 162 | }); 163 | const target = getSdkTarget(window); 164 | 165 | expect(target).toBe(parent); 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/utils/postMessage.ts: -------------------------------------------------------------------------------- 1 | import { JsonObject } from '../types/JsonTypes'; 2 | 3 | export enum MessageCodes { 4 | LaunchEmbedded = 'launch_embedded', 5 | AppReady = 'app_ready', 6 | AppParams = 'app_params', 7 | SigninSuccess = 'signin_success', 8 | Success = 'success', // TODO: deprecate 9 | Exit = 'exit', // TODO: deprecate 10 | Event = 'event', 11 | Error = 'error', 12 | 13 | PixelReady = 'pixel_ready', 14 | OnAppParamsNonce = 'on_app_params_nonce', 15 | } 16 | 17 | export type MessageCode = `${MessageCodes}`; 18 | 19 | export type MessageData = JsonObject; 20 | 21 | export type PostMessageData = { 22 | eventName: MessageCode; 23 | data?: MessageData; 24 | }; 25 | 26 | export const onBroadcastedPostMessage = ( 27 | messageCode: MessageCode, 28 | { 29 | onMessage: callback, 30 | shouldUnsubscribe = true, 31 | allowedOrigin, 32 | onValidateOrigin = () => Promise.resolve(true), 33 | }: { 34 | onMessage: (data?: MessageData) => void; 35 | shouldUnsubscribe?: boolean; 36 | allowedOrigin?: string; 37 | onValidateOrigin?: (origin: string) => Promise; 38 | }, 39 | ): (() => void) => { 40 | const onMessage = (e: MessageEvent) => { 41 | const { eventName, data } = parsePostMessage(e.data as string); 42 | const isOriginAllowed = !allowedOrigin || e.origin === allowedOrigin; 43 | 44 | if (eventName === messageCode) { 45 | void (async () => { 46 | if (isOriginAllowed && (await onValidateOrigin(e.origin))) { 47 | callback(data); 48 | if (shouldUnsubscribe) { 49 | window.removeEventListener('message', onMessage); 50 | } 51 | } 52 | })(); 53 | } 54 | }; 55 | 56 | window.addEventListener('message', onMessage); 57 | 58 | // Unsubscribe 59 | return () => { 60 | window.removeEventListener('message', onMessage); 61 | }; 62 | }; 63 | 64 | export type SdkTarget = Window | { postMessage: typeof window.postMessage }; 65 | 66 | export const getSdkTarget = (win: Window): SdkTarget | undefined => { 67 | if (win !== window) { 68 | // Internal to SDK 69 | return win; 70 | } else if (isMobileSdkTarget(win)) { 71 | // Mobile SDK 72 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 73 | return { postMessage: (message: string) => win.ReactNativeWebView!.postMessage!(message) }; 74 | } else if (win.opener) { 75 | // Button proxy 76 | return win.opener; 77 | } else if (win.parent !== win.self) { 78 | // Third party / SDK 79 | return win.parent; 80 | } else { 81 | return undefined; 82 | } 83 | }; 84 | 85 | const isMobileSdkTarget = (win: Window) => { 86 | try { 87 | return win.ReactNativeWebView?.postMessage !== undefined; 88 | } catch { 89 | return false; 90 | } 91 | }; 92 | 93 | export const broadcastPostMessage = ( 94 | win: SdkTarget, 95 | eventName: MessageCode, 96 | { allowedOrigin = '*', data }: { allowedOrigin?: string; data?: MessageData } = {}, 97 | ): void => { 98 | const message = formatPostMessage(eventName, data); 99 | win.postMessage(message, allowedOrigin); 100 | }; 101 | 102 | const parsePostMessage = (data: string): PostMessageData => { 103 | try { 104 | return JSON.parse(data) as PostMessageData; 105 | } catch { 106 | return { eventName: data as MessageCode }; // event name only 107 | } 108 | }; 109 | 110 | const formatPostMessage = ( 111 | eventName: PostMessageData['eventName'], 112 | data?: PostMessageData['data'], 113 | ): string => { 114 | if (data) { 115 | return JSON.stringify({ eventName, data }); 116 | } 117 | return eventName; 118 | }; 119 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "baseUrl": "./src", 5 | "target": "ES2019", 6 | "module": "commonjs", 7 | "lib": ["dom", "ES2019"], 8 | "jsx": "react", 9 | "declaration": true, 10 | "strict": true, 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "types": ["jest", "chrome"], 17 | "declarationMap": true 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | splitting: true, 6 | sourcemap: true, 7 | clean: true, 8 | dts: true, 9 | format: ['cjs', 'esm'], 10 | target: 'es5', 11 | }); 12 | --------------------------------------------------------------------------------