From 0639cad64c844d0db85f410c95fee3fcf3b101ba Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 11 Dec 2023 16:45:05 -0800 Subject: [PATCH] [PAY-2128] Implement general coinflow scaffolding (#6847) Co-authored-by: Randy Schott <1815175+schottra@users.noreply.github.com> Co-authored-by: Dharit Tantiviramanond Co-authored-by: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> --- package-lock.json | 257 +++++++++++++++++- .../src/hooks/purchaseContent/constants.ts | 1 + .../usePurchaseContentFormConfiguration.ts | 15 +- .../src/hooks/purchaseContent/validation.ts | 6 +- .../common/src/hooks/usePurchaseMethod.ts | 19 +- packages/common/src/models/Analytics.ts | 12 +- packages/common/src/models/PurchaseContent.ts | 3 +- .../src/services/audius-backend/solana.ts | 38 +++ .../services/remote-config/feature-flags.ts | 6 +- packages/common/src/store/buy-usdc/sagas.ts | 121 +++++---- .../common/src/store/buy-usdc/selectors.ts | 2 +- packages/common/src/store/buy-usdc/slice.ts | 16 +- packages/common/src/store/buy-usdc/types.ts | 6 - .../src/store/purchase-content/sagas.ts | 194 ++++++++++--- .../src/store/purchase-content/slice.ts | 12 +- packages/common/src/store/reducers.ts | 2 + .../src/store/ui/coinflow-modal/slice.ts | 25 ++ packages/common/src/store/ui/index.ts | 5 + .../ui/modals/coinflow-onramp-modal/index.ts | 33 +++ packages/common/src/store/ui/modals/index.ts | 1 + .../common/src/store/ui/modals/parentSlice.ts | 1 + .../common/src/store/ui/modals/reducers.ts | 4 +- packages/common/src/store/ui/modals/types.ts | 3 + .../src/utils/passwordListLazyLoader.ts | 1 - .../button/FilterButton/FilterButton.tsx | 26 +- .../harmony/src/components/button/types.ts | 2 +- .../src/services/solana/SolanaWeb3Manager.ts | 15 +- packages/mobile/.env.dev | 2 + packages/mobile/.env.prod | 2 + packages/mobile/.env.stage | 2 + packages/mobile/ios/Podfile.lock | 4 +- packages/mobile/package.json | 3 +- packages/mobile/src/app/Drawers.tsx | 2 + .../add-funds-drawer/AddFundsDrawer.tsx | 26 +- .../CoinflowOnrampDrawer.tsx | 158 +++++++++++ .../payment-method/CardSelectionButton.tsx | 9 +- .../payment-method/PaymentMethod.tsx | 24 +- .../PremiumTrackPurchaseDrawer.tsx | 4 +- .../PurchaseVendorDrawer.tsx | 20 +- .../mobile/src/store/purchase-vendor/slice.ts | 2 +- packages/mobile/src/utils/zIndex.ts | 1 + packages/web/.env/.env.dev | 4 +- packages/web/.env/.env.prod | 4 +- packages/web/.env/.env.stage | 4 +- packages/web/package.json | 1 + .../add-funds-modal/AddFundsModal.tsx | 34 ++- .../web/src/components/add-funds/AddFunds.tsx | 19 +- .../CoinflowOnrampModal.module.css | 16 ++ .../CoinflowOnrampModal.tsx | 118 ++++++++ .../components/coinflow-onramp-modal/index.ts | 1 + packages/web/src/components/drawer/Drawer.tsx | 4 +- .../MobileFilterButton.tsx | 19 +- .../payment-method/PaymentMethod.tsx | 44 ++- .../components/PurchaseContentFormFields.tsx | 29 +- packages/web/src/pages/modals/Modals.tsx | 2 + packages/web/src/utils/zIndex.ts | 1 + 56 files changed, 1140 insertions(+), 245 deletions(-) create mode 100644 packages/common/src/store/ui/coinflow-modal/slice.ts create mode 100644 packages/common/src/store/ui/modals/coinflow-onramp-modal/index.ts create mode 100644 packages/mobile/src/components/coinflow-onramp-drawer/CoinflowOnrampDrawer.tsx create mode 100644 packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.module.css create mode 100644 packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx create mode 100644 packages/web/src/components/coinflow-onramp-modal/index.ts diff --git a/package-lock.json b/package-lock.json index 4ccf21e1b41..d1c01b982cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3269,6 +3269,174 @@ "node": ">= 12" } }, + "node_modules/@coinflowlabs/react": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@coinflowlabs/react/-/react-2.7.0.tgz", + "integrity": "sha512-8YiHS1257O5AMheQTK106I4r6Ql0lvbb/t+mk1ksUXkuYmrwfnDkFjawMGhZFGsbYRvObhqGO9BTCidWNUYRRQ==", + "dependencies": { + "bn.js": "^5.2.1", + "bs58": "^5.0.0", + "socket.io-client": "^4.7.2" + }, + "peerDependencies": { + "@solana/web3.js": ">=1.54.0", + "react": ">=16" + }, + "peerDependenciesMeta": { + "@solana/web3.js": { + "optional": true + } + } + }, + "node_modules/@coinflowlabs/react-native": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@coinflowlabs/react-native/-/react-native-2.1.1.tgz", + "integrity": "sha512-PmPckJdNTKf43T4BLhTVvDacajo3sArOxUSK0ab5ZA8xgEkILW1S5j0VtVo2zfNfyFt7z4HFHfMnyQ8mTM3EoQ==", + "dependencies": { + "@solana/web3.js": "^1.87.3", + "bn.js": "^5.2.1", + "bs58": "^5.0.0", + "react-native-get-random-values": "^1.10.0" + }, + "peerDependencies": { + "react": ">=16", + "react-native": ">=0.66.0", + "react-native-webview": ">=11.16.0" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/@babel/runtime": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/@solana/web3.js": { + "version": "1.87.6", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.87.6.tgz", + "integrity": "sha512-LkqsEBgTZztFiccZZXnawWa8qNCATEqE97/d0vIwjTclmVlc8pBpD1DmjfVHtZ1HS5fZorFlVhXfpwnCNDZfyg==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.3.1", + "@solana/buffer-layout": "^4.0.0", + "agentkeepalive": "^4.3.0", + "bigint-buffer": "^1.1.5", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.0", + "node-fetch": "^2.6.12", + "rpc-websockets": "^7.5.1", + "superstruct": "^0.14.2" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/@solana/web3.js/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/borsh/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/bs58/node_modules/base-x": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", + "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + }, + "node_modules/@coinflowlabs/react-native/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@coinflowlabs/react-native/node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/@coinflowlabs/react-native/node_modules/superstruct": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.14.2.tgz", + "integrity": "sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ==" + }, + "node_modules/@coinflowlabs/react-native/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/@coinflowlabs/react-native/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/@coinflowlabs/react-native/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/@coinflowlabs/react/node_modules/base-x": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", + "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + }, + "node_modules/@coinflowlabs/react/node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "dependencies": { + "base-x": "^4.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -15639,6 +15807,11 @@ "react-native": "*" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@solana-mobile/mobile-wallet-adapter-protocol": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/@solana-mobile/mobile-wallet-adapter-protocol/-/mobile-wallet-adapter-protocol-0.9.9.tgz", @@ -52743,6 +52916,46 @@ "objectorarray": "^1.0.5" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz", @@ -90656,9 +90869,9 @@ } }, "node_modules/react-native-get-random-values": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.8.0.tgz", - "integrity": "sha512-H/zghhun0T+UIJLmig3+ZuBCvF66rdbiWUfRSNS6kv5oDSpa1ZiVyvRWtuPesQpT8dXj+Bv7WJRQOUP+5TB1sA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.10.0.tgz", + "integrity": "sha512-gZ1zbXhbb8+Jy9qYTV8c4Nf45/VB4g1jmXuavY5rPfUn7x3ok9Vl3FTl0dnE92Z4FFtfbUNNwtSfcmomdtWg+A==", "dependencies": { "fast-base64-decode": "^1.0.0" }, @@ -103103,6 +103316,32 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/socket.io-client": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", + "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sockjs-client": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.1.5.tgz", @@ -118834,6 +119073,14 @@ "node": ">=0.4.0" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xregexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz", @@ -143953,6 +144200,7 @@ "@audius/fixed-decimal": "*", "@audius/harmony": "*", "@audius/sdk": "*", + "@coinflowlabs/react-native": "2.1.1", "@emotion/native": "11.11.0", "@emotion/react": "11.11.1", "@fingerprintjs/fingerprintjs-pro-react-native": "2.0.0-test.2", @@ -144026,7 +144274,7 @@ "react-native-fast-image": "8.6.3", "react-native-fs": "2.18.0", "react-native-gesture-handler": "1.10.3", - "react-native-get-random-values": "1.8.0", + "react-native-get-random-values": "1.10.0", "react-native-google-cast": "4.6.0", "react-native-haptic-feedback": "1.11.0", "react-native-image-crop-picker": "0.40.0", @@ -153039,6 +153287,7 @@ "@audius/stems": "*", "@audius/trpc-server": "*", "@coinbase/cbpay-js": "1.2.0", + "@coinflowlabs/react": "2.7.0", "@emotion/css": "^11.11.2", "@emotion/styled": "^11.11.0", "@fingerprintjs/fingerprintjs-pro": "3.5.6", diff --git a/packages/common/src/hooks/purchaseContent/constants.ts b/packages/common/src/hooks/purchaseContent/constants.ts index be4dc23c401..37817a7a890 100644 --- a/packages/common/src/hooks/purchaseContent/constants.ts +++ b/packages/common/src/hooks/purchaseContent/constants.ts @@ -1,6 +1,7 @@ export const CUSTOM_AMOUNT = 'customAmount' export const AMOUNT_PRESET = 'amountPreset' export const PURCHASE_METHOD = 'purchaseMethod' +export const PURCHASE_VENDOR = 'purchaseVendor' export const PAGE = 'page' // Pay between $1 and $100 extra diff --git a/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts b/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts index 8e49d75d926..24f197806c0 100644 --- a/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts +++ b/packages/common/src/hooks/purchaseContent/usePurchaseContentFormConfiguration.ts @@ -21,7 +21,8 @@ import { AMOUNT_PRESET, CENTS_TO_USDC_MULTIPLIER, CUSTOM_AMOUNT, - PURCHASE_METHOD + PURCHASE_METHOD, + PURCHASE_VENDOR } from './constants' import { PayExtraAmountPresetValues, PayExtraPreset } from './types' import { getExtraAmount } from './utils' @@ -50,17 +51,24 @@ export const usePurchaseContentFormConfiguration = ({ const isUnlocking = !error && isContentPurchaseInProgress(stage) const { data: balanceBN } = useUSDCBalance() const balance = USDC(balanceBN ?? new BN(0)).value + const initialValues: PurchaseContentValues = { [CUSTOM_AMOUNT]: undefined, [AMOUNT_PRESET]: PayExtraPreset.NONE, [PURCHASE_METHOD]: balance >= BigInt(price * CENTS_TO_USDC_MULTIPLIER) ? PurchaseMethod.BALANCE - : PurchaseMethod.CARD + : PurchaseMethod.CARD, + [PURCHASE_VENDOR]: undefined } const onSubmit = useCallback( - ({ customAmount, amountPreset, purchaseMethod }: PurchaseContentValues) => { + ({ + customAmount, + amountPreset, + purchaseMethod, + purchaseVendor + }: PurchaseContentValues) => { if (isUnlocking || !track?.track_id) return if ( @@ -77,6 +85,7 @@ export const usePurchaseContentFormConfiguration = ({ dispatch( startPurchaseContentFlow({ purchaseMethod, + purchaseVendor, extraAmount, extraAmountPreset: amountPreset, contentId: track.track_id, diff --git a/packages/common/src/hooks/purchaseContent/validation.ts b/packages/common/src/hooks/purchaseContent/validation.ts index fdd2ba9dd97..c7e2ed4df95 100644 --- a/packages/common/src/hooks/purchaseContent/validation.ts +++ b/packages/common/src/hooks/purchaseContent/validation.ts @@ -1,10 +1,11 @@ import { z } from 'zod' -import { PurchaseMethod } from 'models/PurchaseContent' +import { PurchaseMethod, PurchaseVendor } from 'models/PurchaseContent' import { AMOUNT_PRESET, CUSTOM_AMOUNT, + PURCHASE_VENDOR, PURCHASE_METHOD, maximumPayExtraAmountCents, minimumPayExtraAmountCents @@ -25,7 +26,8 @@ const createPurchaseContentSchema = () => { }) .optional(), [AMOUNT_PRESET]: z.nativeEnum(PayExtraPreset), - [PURCHASE_METHOD]: z.nativeEnum(PurchaseMethod) + [PURCHASE_METHOD]: z.nativeEnum(PurchaseMethod), + [PURCHASE_VENDOR]: z.nativeEnum(PurchaseVendor).optional() }) .refine( ({ amountPreset, customAmount }) => { diff --git a/packages/common/src/hooks/usePurchaseMethod.ts b/packages/common/src/hooks/usePurchaseMethod.ts index 314a414cf53..14f798d5b40 100644 --- a/packages/common/src/hooks/usePurchaseMethod.ts +++ b/packages/common/src/hooks/usePurchaseMethod.ts @@ -1,3 +1,5 @@ +import { useEffect } from 'react' + import { USDC } from '@audius/fixed-decimal' import BN from 'bn.js' @@ -28,13 +30,18 @@ export const usePurchaseMethod = ({ const isExistingBalanceDisabled = USDC(totalPriceInCents / 100).value > balanceUSDC - if (balance) { - if (!isExistingBalanceDisabled && !method) { - setMethod(PurchaseMethod.BALANCE) - } else if (isExistingBalanceDisabled && method === PurchaseMethod.BALANCE) { - setMethod(PurchaseMethod.CARD) + useEffect(() => { + if (balance) { + if (!isExistingBalanceDisabled && !method) { + setMethod(PurchaseMethod.BALANCE) + } else if ( + isExistingBalanceDisabled && + method === PurchaseMethod.BALANCE + ) { + setMethod(PurchaseMethod.CARD) + } } - } + }, [balance, isExistingBalanceDisabled, method, setMethod]) return { isExistingBalanceDisabled, totalPriceInCents } } diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index b52e5e15098..8d1062cbc8d 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1656,34 +1656,34 @@ type BuyAudioRecoveryFailure = { // Buy USDC type BuyUSDCOnRampOpened = { eventName: Name.BUY_USDC_ON_RAMP_OPENED - provider: string + vendor: string } type BuyUSDCOnRampCanceled = { eventName: Name.BUY_USDC_ON_RAMP_CANCELED - provider: string + vendor: string } type BuyUSDCOnRampFailed = { eventName: Name.BUY_USDC_ON_RAMP_FAILURE error: string - provider: string + vendor: string } type BuyUSDCOnRampSuccess = { eventName: Name.BUY_USDC_ON_RAMP_SUCCESS - provider: string + vendor: string } type BuyUSDCSuccess = { eventName: Name.BUY_USDC_SUCCESS - provider: string + vendor: string requestedAmount: number } type BuyUSDCFailure = { eventName: Name.BUY_USDC_FAILURE - provider: string + vendor: string requestedAmount: number error: string } diff --git a/packages/common/src/models/PurchaseContent.ts b/packages/common/src/models/PurchaseContent.ts index 17886c0a390..28180a5f16a 100644 --- a/packages/common/src/models/PurchaseContent.ts +++ b/packages/common/src/models/PurchaseContent.ts @@ -5,5 +5,6 @@ export enum PurchaseMethod { } export enum PurchaseVendor { - STRIPE = 'Stripe' + STRIPE = 'Stripe', + COINFLOW = 'Coinflow' } diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 069a373d7b7..4504573338f 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -367,6 +367,44 @@ export const purchaseContent = async ( ).solanaWeb3Manager!.purchaseContent(config) } +export type PurchaseContentWithPaymentRouterArgs = { + id: number + type: 'track' + splits: Record + extraAmount?: number + blocknumber: number + recentBlockhash?: string + purchaserUserId: ID + wallet: Keypair +} + +export const purchaseContentWithPaymentRouter = async ( + audiusBackendInstance: AudiusBackend, + { + id, + type, + blocknumber, + extraAmount = 0, + purchaserUserId, + splits, + wallet + }: PurchaseContentWithPaymentRouterArgs +) => { + const solanaWeb3Manager = (await audiusBackendInstance.getAudiusLibs()) + .solanaWeb3Manager! + const tx = await solanaWeb3Manager.purchaseContentWithPaymentRouter({ + id, + type, + blocknumber, + extraAmount, + splits, + purchaserUserId, + senderKeypair: wallet, + skipSendAndReturnTransaction: true + }) + return tx +} + export const findAssociatedTokenAddress = async ( audiusBackendInstance: AudiusBackend, { solanaAddress, mint }: { solanaAddress: string; mint: MintName } diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 90a46547887..dcbe3f3f562 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -54,7 +54,8 @@ export enum FeatureFlags { FEATURE_FLAG_ACCESS = 'feature_flag_access', BUY_USDC_VIA_SOL = 'buy_usdc_via_sol', IOS_USDC_PURCHASE_ENABLED = 'ios_usdc_purchase_enabled', - SCHEDULED_RELEASES = 'scheduled_releases' + SCHEDULED_RELEASES = 'scheduled_releases', + BUY_WITH_COINFLOW = 'buy_with_coinflow' } type FlagDefaults = Record @@ -124,5 +125,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.FEATURE_FLAG_ACCESS]: false, [FeatureFlags.BUY_USDC_VIA_SOL]: false, [FeatureFlags.IOS_USDC_PURCHASE_ENABLED]: true, - [FeatureFlags.SCHEDULED_RELEASES]: false + [FeatureFlags.SCHEDULED_RELEASES]: false, + [FeatureFlags.BUY_WITH_COINFLOW]: false } diff --git a/packages/common/src/store/buy-usdc/sagas.ts b/packages/common/src/store/buy-usdc/sagas.ts index b716a2d7e0c..e9252a452c6 100644 --- a/packages/common/src/store/buy-usdc/sagas.ts +++ b/packages/common/src/store/buy-usdc/sagas.ts @@ -5,6 +5,7 @@ import { call, put, race, select, take, takeLeading } from 'typed-redux-saga' import { Name } from 'models/Analytics' import { ErrorLevel } from 'models/ErrorReporting' +import { PurchaseVendor } from 'models/PurchaseContent' import { createTransferToUserBankTransaction, findAssociatedTokenAddress, @@ -32,13 +33,13 @@ import { startRecoveryIfNecessary, recoveryStatusChanged } from './slice' -import { BuyUSDCError, BuyUSDCErrorCode, USDCOnRampProvider } from './types' +import { BuyUSDCError, BuyUSDCErrorCode } from './types' import { getBuyUSDCRemoteConfig, getUSDCUserBank } from './utils' type PurchaseStepParams = { desiredAmount: number wallet: PublicKey - provider: USDCOnRampProvider + vendor: PurchaseVendor retryDelayMs?: number maxRetryCount?: number } @@ -46,7 +47,7 @@ type PurchaseStepParams = { function* purchaseStep({ desiredAmount, wallet, - provider, + vendor, retryDelayMs, maxRetryCount }: PurchaseStepParams) { @@ -82,7 +83,7 @@ function* purchaseStep({ if (result.canceled) { yield* call( track, - make({ eventName: Name.BUY_USDC_ON_RAMP_CANCELED, provider }) + make({ eventName: Name.BUY_USDC_ON_RAMP_CANCELED, vendor }) ) return {} } else if (result.failure) { @@ -94,7 +95,7 @@ function* purchaseStep({ track, make({ eventName: Name.BUY_USDC_ON_RAMP_FAILURE, - provider, + vendor, error: errorString }) ) @@ -107,10 +108,7 @@ function* purchaseStep({ } throw new BuyUSDCError(BuyUSDCErrorCode.OnrampError, errorString) } - yield* call( - track, - make({ eventName: Name.BUY_USDC_ON_RAMP_SUCCESS, provider }) - ) + yield* call(track, make({ eventName: Name.BUY_USDC_ON_RAMP_SUCCESS, vendor })) // Wait for the funds to come through const newBalance = yield* call( @@ -209,7 +207,7 @@ function* transferStep({ function* doBuyUSDC({ payload: { - provider, + vendor, purchaseInfo: { desiredAmount } } }: ReturnType) { @@ -222,13 +220,6 @@ function* doBuyUSDC({ const rootAccount = yield* call(getRootSolanaAccount, audiusBackendInstance) try { - if (provider !== USDCOnRampProvider.STRIPE) { - throw new BuyUSDCError( - BuyUSDCErrorCode.OnrampError, - 'USDC Purchase is only supported via Stripe' - ) - } - if (desiredAmount < config.minUSDCPurchaseAmountCents) { throw new BuyUSDCError( BuyUSDCErrorCode.MinAmountNotMet, @@ -242,53 +233,65 @@ function* doBuyUSDC({ `Maximum USDC purchase amount is ${config.maxUSDCPurchaseAmountCents} cents` ) } + switch (vendor) { + case PurchaseVendor.STRIPE: { + yield* put( + initializeStripeModal({ + // stripe expects amount in dollars, not cents + amount: (desiredAmount / 100).toString(), + destinationCurrency: 'usdc', + destinationWallet: rootAccount.publicKey.toString(), + onrampCanceled, + onrampFailed, + onrampSucceeded + }) + ) - yield* put( - initializeStripeModal({ - // stripe expects amount in dollars, not cents - amount: (desiredAmount / 100).toString(), - destinationCurrency: 'usdc', - destinationWallet: rootAccount.publicKey.toString(), - onrampCanceled, - onrampFailed, - onrampSucceeded - }) - ) - - yield* put(setVisibility({ modal: 'StripeOnRamp', visible: true })) + yield* put(setVisibility({ modal: 'StripeOnRamp', visible: true })) - // Record start - yield* call( - track, - make({ eventName: Name.BUY_USDC_ON_RAMP_OPENED, provider }) - ) + // Record start + yield* call( + track, + make({ eventName: Name.BUY_USDC_ON_RAMP_OPENED, vendor }) + ) - // Get config - const { retryDelayMs, maxRetryCount } = yield* call(getBuyUSDCRemoteConfig) + // Get config + const { retryDelayMs, maxRetryCount } = yield* call( + getBuyUSDCRemoteConfig + ) - // Wait for purchase - // Have to do some typescript finangling here due to the "race" effect in purchaseStep - // See https://github.com/agiledigital/typed-redux-saga/issues/43 - const { newBalance } = (yield* call(purchaseStep, { - provider, - desiredAmount, - wallet: rootAccount.publicKey, - retryDelayMs, - maxRetryCount - }) as unknown as ReturnType)! + // Wait for purchase + // Have to do some typescript finangling here due to the "race" effect in purchaseStep + // See https://github.com/agiledigital/typed-redux-saga/issues/43 + const { newBalance } = (yield* call(purchaseStep, { + vendor, + desiredAmount, + wallet: rootAccount.publicKey, + retryDelayMs, + maxRetryCount + }) as unknown as ReturnType)! + + // If the user canceled the purchase, stop the flow + if (newBalance === undefined) { + return + } - // If the user canceled the purchase, stop the flow - if (newBalance === undefined) { - return + // Transfer from the root wallet to the userbank + yield* call(transferStep, { + wallet: rootAccount, + userBank, + amount: newBalance + }) + break + } + case PurchaseVendor.COINFLOW: + default: + throw new BuyUSDCError( + BuyUSDCErrorCode.OnrampError, + 'Unsupported vendor' + ) } - // Transfer from the root wallet to the userbank - yield* call(transferStep, { - wallet: rootAccount, - userBank, - amount: newBalance - }) - yield* put(buyUSDCFlowSucceeded()) // Record success @@ -296,7 +299,7 @@ function* doBuyUSDC({ track, make({ eventName: Name.BUY_USDC_SUCCESS, - provider, + vendor, requestedAmount: desiredAmount }) ) @@ -315,7 +318,7 @@ function* doBuyUSDC({ track, make({ eventName: Name.BUY_USDC_FAILURE, - provider, + vendor, requestedAmount: desiredAmount, error: error.message }) diff --git a/packages/common/src/store/buy-usdc/selectors.ts b/packages/common/src/store/buy-usdc/selectors.ts index 391cae42c12..a4826eb4072 100644 --- a/packages/common/src/store/buy-usdc/selectors.ts +++ b/packages/common/src/store/buy-usdc/selectors.ts @@ -1,6 +1,6 @@ import { CommonState } from 'store/reducers' -export const getBuyUSDCProvider = (state: CommonState) => state.buyUSDC.provider +export const getBuyUSDCVendor = (state: CommonState) => state.buyUSDC.vendor export const getBuyUSDCFlowStage = (state: CommonState) => state.buyUSDC.stage diff --git a/packages/common/src/store/buy-usdc/slice.ts b/packages/common/src/store/buy-usdc/slice.ts index 4242742704c..1d459cdbcb6 100644 --- a/packages/common/src/store/buy-usdc/slice.ts +++ b/packages/common/src/store/buy-usdc/slice.ts @@ -1,13 +1,9 @@ import { Action, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { PurchaseVendor } from 'models/PurchaseContent' import { StripeSessionCreationError } from 'store/ui/stripe-modal/types' -import { - BuyUSDCStage, - USDCOnRampProvider, - PurchaseInfo, - BuyUSDCError -} from './types' +import { BuyUSDCStage, PurchaseInfo, BuyUSDCError } from './types' type StripeSessionStatus = | 'initialized' @@ -26,14 +22,14 @@ type RecoveryStatus = 'idle' | 'in-progress' | 'success' | 'failure' type BuyUSDCState = { stage: BuyUSDCStage error?: BuyUSDCError - provider: USDCOnRampProvider + vendor?: PurchaseVendor onSuccess?: OnSuccess stripeSessionStatus?: StripeSessionStatus recoveryStatus: RecoveryStatus } const initialState: BuyUSDCState = { - provider: USDCOnRampProvider.UNKNOWN, + vendor: undefined, stage: BuyUSDCStage.START, recoveryStatus: 'idle' } @@ -46,12 +42,12 @@ const slice = createSlice({ state, action: PayloadAction<{ purchaseInfo: PurchaseInfo - provider: USDCOnRampProvider + vendor: PurchaseVendor }> ) => { state.stage = BuyUSDCStage.START state.error = undefined - state.provider = action.payload.provider + state.vendor = action.payload.vendor }, purchaseStarted: (state) => { state.stage = BuyUSDCStage.PURCHASING diff --git a/packages/common/src/store/buy-usdc/types.ts b/packages/common/src/store/buy-usdc/types.ts index 4b01dbfa77d..5310b3bbd29 100644 --- a/packages/common/src/store/buy-usdc/types.ts +++ b/packages/common/src/store/buy-usdc/types.ts @@ -1,9 +1,3 @@ -export enum USDCOnRampProvider { - COINBASE = 'coinbase', - STRIPE = 'stripe', - UNKNOWN = 'unknown' -} - export type PurchaseInfo = { /** * Desired amount of USDC in *cents* diff --git a/packages/common/src/store/purchase-content/sagas.ts b/packages/common/src/store/purchase-content/sagas.ts index 1c63778b11c..bd6b458d431 100644 --- a/packages/common/src/store/purchase-content/sagas.ts +++ b/packages/common/src/store/purchase-content/sagas.ts @@ -5,12 +5,15 @@ import { call, put, race, select, take } from 'typed-redux-saga' import { FavoriteSource, Name } from 'models/Analytics' import { ErrorLevel } from 'models/ErrorReporting' import { ID } from 'models/Identifiers' -import { PurchaseMethod } from 'models/PurchaseContent' +import { PurchaseMethod, PurchaseVendor } from 'models/PurchaseContent' import { isPremiumContentUSDCPurchaseGated } from 'models/Track' import { BNUSDC } from 'models/Wallet' import { + getRecentBlockhash, + getRootSolanaAccount, getTokenAccountInfo, - purchaseContent + purchaseContent, + purchaseContentWithPaymentRouter } from 'services/audius-backend/solana' import { FeatureFlags } from 'services/remote-config/feature-flags' import { accountSelectors } from 'store/account' @@ -27,7 +30,7 @@ import { onrampOpened, onrampCanceled } from 'store/buy-usdc/slice' -import { BuyUSDCError, USDCOnRampProvider } from 'store/buy-usdc/types' +import { BuyUSDCError } from 'store/buy-usdc/types' import { getBuyUSDCRemoteConfig, getUSDCUserBank } from 'store/buy-usdc/utils' import { getTrack } from 'store/cache/tracks/selectors' import { getUser } from 'store/cache/users/selectors' @@ -35,7 +38,14 @@ import { getContext } from 'store/effects' import { getPreviewing, getTrackId } from 'store/player/selectors' import { stop } from 'store/player/slice' import { saveTrack } from 'store/social/tracks/actions' +import { getFeePayer } from 'store/solana/selectors' import { OnRampProvider } from 'store/ui/buy-audio/types' +import { + transactionCanceled, + transactionFailed, + transactionSucceeded +} from 'store/ui/coinflow-modal/slice' +import { coinflowOnrampModalActions } from 'store/ui/modals/coinflow-onramp-modal' import { BN_USDC_CENT_WEI, ceilingBNUSDCToNearestCent } from 'utils/wallet' import { pollPremiumTrack } from '../premium-content/sagas' @@ -162,14 +172,88 @@ function* pollForPurchaseConfirmation({ }) } -/** Attempts to purchase the requested amount of USDC, will throw on cancellation or failure */ -function* purchaseUSDC({ amount }: { amount: BNUSDC }) { +function* purchaseWithCoinflow({ + blocknumber, + extraAmount, + splits, + contentId, + purchaserUserId, + balanceNeededCents +}: { + blocknumber: number + extraAmount?: number + splits: Record + contentId: ID + purchaserUserId: ID + balanceNeededCents: number +}) { + const audiusBackendInstance = yield* getContext('audiusBackendInstance') + const feePayerAddress = yield* select(getFeePayer) + if (!feePayerAddress) { + throw new Error('Missing feePayer unexpectedly') + } + const recentBlockhash = yield* call(getRecentBlockhash, audiusBackendInstance) + const rootAccount = yield* call(getRootSolanaAccount, audiusBackendInstance) + + const coinflowTransaction = yield* call( + purchaseContentWithPaymentRouter, + audiusBackendInstance, + { + id: contentId, + type: 'track', + splits, + extraAmount, + blocknumber, + recentBlockhash, + purchaserUserId, + wallet: rootAccount + } + ) + + const serializedTransaction = coinflowTransaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('base64') + const amount = balanceNeededCents / 100.0 + yield* put( + coinflowOnrampModalActions.open({ + amount, + serializedTransaction, + contentId + }) + ) + + const result = yield* race({ + succeeded: take(transactionSucceeded), + failed: take(transactionFailed), + canceled: take(transactionCanceled) + }) + + // Return early for failure or cancellation + if (result.canceled) { + yield* put(purchaseCanceled()) + return + } + if (result.failed) { + yield* put( + // TODO: better error + purchaseContentFlowFailed({ + error: new PurchaseContentError( + PurchaseErrorCode.Unknown, + 'Coinflow transaction failed' + ) + }) + ) + } +} + +function* purchaseUSDCWithStripe({ balanceNeeded }: { balanceNeeded: BNUSDC }) { + yield* put(buyUSDC()) const getFeatureEnabled = yield* getContext('getFeatureEnabled') const isBuyUSDCViaSolEnabled = yield* call( getFeatureEnabled, FeatureFlags.BUY_USDC_VIA_SOL ) - const roundedAmount = ceilingBNUSDCToNearestCent(amount) + const roundedAmount = ceilingBNUSDCToNearestCent(balanceNeeded) .div(BN_USDC_CENT_WEI) .toNumber() @@ -191,7 +275,7 @@ function* purchaseUSDC({ amount }: { amount: BNUSDC }) { } else { yield* put( onrampOpened({ - provider: USDCOnRampProvider.STRIPE, + vendor: PurchaseVendor.STRIPE, purchaseInfo: { desiredAmount: roundedAmount } @@ -215,6 +299,7 @@ function* purchaseUSDC({ amount }: { amount: BNUSDC }) { if (result.failed) { throw result.failed.payload.error } + yield* put(usdcBalanceSufficient()) } function* doStartPurchaseContentFlow({ @@ -222,6 +307,7 @@ function* doStartPurchaseContentFlow({ extraAmount, extraAmountPreset, purchaseMethod, + purchaseVendor, contentId, contentType = ContentType.TRACK } @@ -241,6 +327,7 @@ function* doStartPurchaseContentFlow({ contentId, contentType, purchaseMethod, + purchaseVendor, contentName: title, artistHandle: artistInfo.handle, isVerifiedArtist: artistInfo.is_verified, @@ -284,51 +371,74 @@ function* doStartPurchaseContentFlow({ const extraAmountBN = new BN(extraAmount ?? 0).mul(BN_USDC_CENT_WEI) const totalAmountDueCentsBN = priceBN.add(extraAmountBN) as BNUSDC - if (purchaseMethod === PurchaseMethod.CARD) { - // Transition to 'buying USDC' stage - yield* put(buyUSDC()) - yield* call(purchaseUSDC, { amount: totalAmountDueCentsBN }) - } - // Manual transfer is a special case of existing balance. We expect the - // transfer to have occurred before this saga is invoked - else { - // No work needed here other than to check that the balance is sufficient - const balanceNeeded = getBalanceNeeded( - totalAmountDueCentsBN, - new BN(initialBalance.toString()) as BNUSDC, - usdcConfig.minUSDCPurchaseAmountCents - ) - if (balanceNeeded.gtn(0)) { + const { blocknumber, splits } = yield* getPurchaseConfig({ + contentId, + contentType + }) + const balanceNeeded = getBalanceNeeded( + totalAmountDueCentsBN, + new BN(initialBalance.toString()) as BNUSDC, + usdcConfig.minUSDCPurchaseAmountCents + ) + + if (balanceNeeded.lten(0)) { + // No balance needed, perform the purchase right away + yield* call(purchaseContent, audiusBackendInstance, { + id: contentId, + blocknumber, + extraAmount: extraAmountBN, + splits, + type: 'track', + purchaserUserId + }) + } else { + // We need to acquire USDC before the purchase can continue + + // Invariant: The user must be checking out with a card + if (purchaseMethod !== PurchaseMethod.CARD) { throw new PurchaseContentError( PurchaseErrorCode.InsufficientBalance, 'Unexpected insufficient balance to complete purchase' ) } + const balanceNeededCents = ceilingBNUSDCToNearestCent(balanceNeeded) + .div(BN_USDC_CENT_WEI) + .toNumber() + + switch (purchaseVendor) { + case PurchaseVendor.COINFLOW: + // Purchase with coinflow, funding and completing the purchase in one step. + yield* call(purchaseWithCoinflow, { + blocknumber, + extraAmount, + splits, + contentId, + purchaserUserId, + balanceNeededCents + }) + break + case PurchaseVendor.STRIPE: + // Buy USDC with Stripe. Once funded, continue with purchase. + yield* call(purchaseUSDCWithStripe, { balanceNeeded }) + yield* call(purchaseContent, audiusBackendInstance, { + id: contentId, + blocknumber, + extraAmount: extraAmountBN, + splits, + type: 'track', + purchaserUserId + }) + break + } } - // Transition to 'purchasing' stage - yield* put(usdcBalanceSufficient()) - - const { blocknumber, splits } = yield* getPurchaseConfig({ - contentId, - contentType - }) - - // purchase content - yield* call(purchaseContent, audiusBackendInstance, { - id: contentId, - blocknumber, - extraAmount: extraAmountBN, - splits, - type: 'track', - purchaserUserId - }) + // Mark the purchase as successful yield* put(purchaseSucceeded()) - // confirm purchase + // Poll to confirm purchase (waiting for a signature) yield* pollForPurchaseConfirmation({ contentId, contentType }) - // auto-favorite the purchased item + // Auto-favorite the purchased item if (contentType === ContentType.TRACK) { yield* put(saveTrack(contentId, FavoriteSource.IMPLICIT)) } @@ -340,7 +450,7 @@ function* doStartPurchaseContentFlow({ yield* put(stop({})) } - // finish + // Finish yield* put(purchaseConfirmed({ contentId, contentType })) yield* call( diff --git a/packages/common/src/store/purchase-content/slice.ts b/packages/common/src/store/purchase-content/slice.ts index 3c3c4d38fca..5d4b40d5c9f 100644 --- a/packages/common/src/store/purchase-content/slice.ts +++ b/packages/common/src/store/purchase-content/slice.ts @@ -1,7 +1,7 @@ import { Action, createSlice, PayloadAction } from '@reduxjs/toolkit' import { ID } from 'models/Identifiers' -import { PurchaseMethod } from 'models/PurchaseContent' +import { PurchaseMethod, PurchaseVendor } from 'models/PurchaseContent' import { ContentType, @@ -27,6 +27,7 @@ type PurchaseContentState = { error?: PurchaseContentError onSuccess?: OnSuccess purchaseMethod: PurchaseMethod + purchaseVendor?: PurchaseVendor } const initialState: PurchaseContentState = { @@ -37,7 +38,8 @@ const initialState: PurchaseContentState = { extraAmountPreset: undefined, error: undefined, stage: PurchaseContentStage.START, - purchaseMethod: PurchaseMethod.BALANCE + purchaseMethod: PurchaseMethod.BALANCE, + purchaseVendor: undefined } const slice = createSlice({ @@ -50,6 +52,7 @@ const slice = createSlice({ extraAmount?: number extraAmountPreset?: string purchaseMethod: PurchaseMethod + purchaseVendor?: PurchaseVendor contentId: ID contentType?: ContentType onSuccess?: OnSuccess @@ -63,6 +66,8 @@ const slice = createSlice({ state.contentId = action.payload.contentId state.contentType = action.payload.contentType ?? ContentType.TRACK state.onSuccess = action.payload.onSuccess + state.purchaseMethod = action.payload.purchaseMethod + state.purchaseVendor = action.payload.purchaseVendor }, buyUSDC: (state) => { state.stage = PurchaseContentStage.BUY_USDC @@ -70,6 +75,9 @@ const slice = createSlice({ usdcBalanceSufficient: (state) => { state.stage = PurchaseContentStage.PURCHASING }, + coinflowPurchaseSucceeded: (_state) => {}, + coinflowPurchaseFailed: (_state) => {}, + coinflowPurchaseCanceled: (_state) => {}, purchaseCanceled: (state) => { state.stage = PurchaseContentStage.CANCELED }, diff --git a/packages/common/src/store/reducers.ts b/packages/common/src/store/reducers.ts index ede79358f0f..e2bd645ff8b 100644 --- a/packages/common/src/store/reducers.ts +++ b/packages/common/src/store/reducers.ts @@ -90,6 +90,7 @@ import addToPlaylistReducer, { AddToPlaylistState } from './ui/add-to-playlist/reducer' import buyAudioReducer from './ui/buy-audio/slice' +import coinflowModalReducer from './ui/coinflow-modal/slice' import collectibleDetailsReducer, { CollectibleDetailsState } from './ui/collectible-details/slice' @@ -199,6 +200,7 @@ export const reducers = (storage: Storage) => ({ shareSoundToTikTokModal: shareSoundToTikTokModalReducer, shareModal: shareModalReducer, stripeModal: stripeModalReducer, + coinflowModal: coinflowModalReducer, searchUsersModal: searchUsersModalReducer, toast: toastReducer, transactionDetails: transactionDetailsReducer, diff --git a/packages/common/src/store/ui/coinflow-modal/slice.ts b/packages/common/src/store/ui/coinflow-modal/slice.ts new file mode 100644 index 00000000000..0d4c3cfa342 --- /dev/null +++ b/packages/common/src/store/ui/coinflow-modal/slice.ts @@ -0,0 +1,25 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +const initialState = {} + +const slice = createSlice({ + name: 'ui/coinflow-modal', + initialState, + reducers: { + transactionSucceeded: (_state, _action: PayloadAction<{}>) => { + // Handled by saga + }, + transactionFailed: (_state, _action: PayloadAction<{}>) => { + // Handled by saga + }, + transactionCanceled: (_state, _action: PayloadAction<{}>) => { + // Handled by saga + } + } +}) + +export const { transactionSucceeded, transactionFailed, transactionCanceled } = + slice.actions + +export default slice.reducer +export const actions = slice.actions diff --git a/packages/common/src/store/ui/index.ts b/packages/common/src/store/ui/index.ts index 4e975e93449..3f73031c132 100644 --- a/packages/common/src/store/ui/index.ts +++ b/packages/common/src/store/ui/index.ts @@ -79,6 +79,11 @@ export * from './stripe-modal/types' export * as stripeModalUISelectors from './stripe-modal/selectors' export { default as stripeModalUISagas } from './stripe-modal/sagas' +export { + default as coinflowModalUIReducer, + actions as coinflowModalUIActions +} from './coinflow-modal/slice' + export { default as vipDiscordModalReducer, actions as vipDiscordModalActions diff --git a/packages/common/src/store/ui/modals/coinflow-onramp-modal/index.ts b/packages/common/src/store/ui/modals/coinflow-onramp-modal/index.ts new file mode 100644 index 00000000000..78317a7ce17 --- /dev/null +++ b/packages/common/src/store/ui/modals/coinflow-onramp-modal/index.ts @@ -0,0 +1,33 @@ +import { Action } from '@reduxjs/toolkit' + +import { ID } from 'models/Identifiers' + +import { createModal } from '../createModal' + +export type CoinflowOnrampModalState = { + amount: number + contentId?: ID + serializedTransaction: string + onrampSucceeded?: Action + onrampCanceled?: Action + onrampFailed?: Action +} + +const coinflowOnrampModal = createModal({ + reducerPath: 'CoinflowOnramp', + initialState: { + contentId: undefined, + isOpen: false, + amount: -1, + serializedTransaction: '' + }, + sliceSelector: (state) => state.ui.modals, + enableTracking: true, + getTrackingData: ({ contentId }) => ({ contentId }) +}) + +export const { + hook: useCoinflowOnrampModal, + actions: coinflowOnrampModalActions, + reducer: coinflowOnrampModalReducer +} = coinflowOnrampModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index ade088a22b5..ddefa97f59d 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -5,6 +5,7 @@ export { rootModalReducer as modalsReducer } from './reducers' export { sagas as modalsSagas } from './sagas' export * from './create-chat-modal' +export * from './coinflow-onramp-modal' export * from './leaving-audius-modal' export * from './inbox-unavailable-modal' export * from './usdc-purchase-details-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 9da3f8f8fda..fa11ba3c978 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -56,6 +56,7 @@ export const initialState: BasicModalsState = { USDCPurchaseDetailsModal: { isOpen: false }, USDCTransactionDetailsModal: { isOpen: false }, USDCManualTransferModal: { isOpen: false }, + CoinflowOnramp: { isOpen: false }, AddFundsModal: { isOpen: false }, Welcome: { isOpen: false } } diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index 8be28eed506..7708648206a 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -1,6 +1,7 @@ import { Action, combineReducers, Reducer } from '@reduxjs/toolkit' import { addFundsModalReducer } from './add-funds-modal' +import { coinflowOnrampModalReducer } from './coinflow-onramp-modal' import { createChatModalReducer } from './create-chat-modal' import { BaseModalState } from './createModal' import { editPlaylistModalReducer } from './edit-playlist-modal' @@ -40,7 +41,8 @@ const combinedReducers = combineReducers({ USDCManualTransferModal: usdcManualTransferModalReducer, AddFundsModal: addFundsModalReducer, USDCTransactionDetailsModal: usdcTransactionDetailsModalReducer, - PremiumContentPurchaseModal: premiumContentPurchaseModalReducer + PremiumContentPurchaseModal: premiumContentPurchaseModalReducer, + CoinflowOnramp: coinflowOnrampModalReducer }) /** diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index affaa7d1d8d..9d1e37d5a1a 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -1,6 +1,7 @@ import { ModalSource } from 'models/Analytics' import { AddFundsModalState } from './add-funds-modal' +import { CoinflowOnrampModalState } from './coinflow-onramp-modal' import { CreateChatModalState } from './create-chat-modal' import { BaseModalState } from './createModal' import { EditPlaylistModalState } from './edit-playlist-modal' @@ -46,6 +47,7 @@ export type Modals = | 'TransactionDetails' | 'VipDiscord' | 'StripeOnRamp' + | 'CoinflowOnramp' | 'InboxSettings' | 'LockedContent' | 'PlaybackRate' @@ -70,6 +72,7 @@ export type BasicModalsState = { } export type StatefulModalsState = { + CoinflowOnramp: CoinflowOnrampModalState CreateChatModal: CreateChatModalState EditPlaylist: EditPlaylistModalState EditTrack: EditTrackModalState diff --git a/packages/common/src/utils/passwordListLazyLoader.ts b/packages/common/src/utils/passwordListLazyLoader.ts index 65844cc0bc4..003a694bd83 100644 --- a/packages/common/src/utils/passwordListLazyLoader.ts +++ b/packages/common/src/utils/passwordListLazyLoader.ts @@ -1,4 +1,3 @@ import commonPasswordList from 'fxa-common-password-list' export { commonPasswordList } - diff --git a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx index a6fad235be9..f61f6c54f03 100644 --- a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx +++ b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx @@ -1,4 +1,11 @@ -import { forwardRef, RefObject, useRef, useState, useCallback } from 'react' +import { + forwardRef, + RefObject, + useRef, + useState, + useCallback, + useEffect +} from 'react' import { CSSObject, useTheme } from '@emotion/react' @@ -34,6 +41,13 @@ export const FilterButton = forwardRef( ? options[initialSelectionIndex] : null ) + + useEffect(() => { + if (onSelect && selection?.label) { + onSelect(selection.label) + } + }, [selection?.label, onSelect]) + const [isOpen, setIsOpen] = useState(false) // Size Styles @@ -148,13 +162,9 @@ export const FilterButton = forwardRef( } }, [selection, variant, setIsOpen, setSelection]) - const handleOptionSelect = useCallback( - (option: FilterButtonOption) => { - setSelection(option) - onSelect?.(option) - }, - [onSelect] - ) + const handleOptionSelect = useCallback((option: FilterButtonOption) => { + setSelection(option) + }, []) const anchorRef = useRef(null) diff --git a/packages/harmony/src/components/button/types.ts b/packages/harmony/src/components/button/types.ts index e815dec629b..07e4dfe0322 100644 --- a/packages/harmony/src/components/button/types.ts +++ b/packages/harmony/src/components/button/types.ts @@ -208,7 +208,7 @@ export type FilterButtonProps = { /** * What to do when an option is selected */ - onSelect?: (option: FilterButtonOption) => void + onSelect?: (label: string) => void /** * Popup anchor origin diff --git a/packages/libs/src/services/solana/SolanaWeb3Manager.ts b/packages/libs/src/services/solana/SolanaWeb3Manager.ts index 9f1810e9815..2ba7fbecd8e 100644 --- a/packages/libs/src/services/solana/SolanaWeb3Manager.ts +++ b/packages/libs/src/services/solana/SolanaWeb3Manager.ts @@ -661,12 +661,6 @@ export class SolanaWeb3Manager { senderAccount.toString(), 'usdc' ) - const senderTokenAccountInfo = await this.getTokenAccountInfo( - senderTokenAccount.toString() - ) - if (senderTokenAccountInfo === null) { - throw new Error('Sender token account ATA does not exist') - } const amounts = Object.values(recipientAmounts) const totalAmount = amounts.reduce( @@ -699,7 +693,7 @@ export class SolanaWeb3Manager { const memoInstruction = new TransactionInstruction({ keys: [ { - pubkey: new PublicKey(this.feePayerKey), + pubkey: senderAccount, isSigner: true, isWritable: true } @@ -723,7 +717,8 @@ export class SolanaWeb3Manager { extraAmount = 0, splits, purchaserUserId, - senderKeypair + senderKeypair, + skipSendAndReturnTransaction }: { id: number type: 'track' @@ -732,6 +727,7 @@ export class SolanaWeb3Manager { blocknumber: number purchaserUserId: number senderKeypair: Keypair + skipSendAndReturnTransaction?: boolean }) { const instructions = await this.getPurchaseContentWithPaymentRouterInstructions({ @@ -751,6 +747,9 @@ export class SolanaWeb3Manager { recentBlockhash: recentBlockhash }).add(...instructions) transaction.partialSign(senderKeypair) + if (skipSendAndReturnTransaction) { + return transaction + } const signatures = transaction.signatures .filter((s) => s.signature !== null) .map((s) => ({ diff --git a/packages/mobile/.env.dev b/packages/mobile/.env.dev index b6942c836fa..b6c18425710 100644 --- a/packages/mobile/.env.dev +++ b/packages/mobile/.env.dev @@ -52,3 +52,5 @@ HCAPTCHA_SITE_KEY=2abe61f1-af6e-4707-be19-a9a4146a9bea OPENSEA_API_URL=https://rinkeby-api.opensea.io/api/v1 REACT_APP_STRIPE_CLIENT_PUBLISHABLE_KEY= + +COINFLOW_MERCHANT_ID=audius diff --git a/packages/mobile/.env.prod b/packages/mobile/.env.prod index 87698547b8a..d6c56809aed 100644 --- a/packages/mobile/.env.prod +++ b/packages/mobile/.env.prod @@ -94,3 +94,5 @@ FINGERPRINT_ENDPOINT=https://fp.audius.co OLD_WEB_APP_STATIC_SERVER_PORT=3100 REACT_APP_STRIPE_CLIENT_PUBLISHABLE_KEY=pk_live_51LPsGuCJOWtpH6AEKshlCs3L8QhAfevNvhev8K9a0u92O5ku83KRjLIqCdxgf3NhitdtmMGlw0Wjf33NjZJjZUBz006A3IoSiQ + +COINFLOW_MERCHANT_ID=tikilabs diff --git a/packages/mobile/.env.stage b/packages/mobile/.env.stage index 34e73437d4c..da23c47bb96 100644 --- a/packages/mobile/.env.stage +++ b/packages/mobile/.env.stage @@ -79,3 +79,5 @@ FINGERPRINT_ENDPOINT=https://fp.staging.audius.co OLD_WEB_APP_STATIC_SERVER_PORT=3101 REACT_APP_STRIPE_CLIENT_PUBLISHABLE_KEY=pk_test_51LPsGuCJOWtpH6AEZT3Wf2U2xmLZQrEV56yha7HEVTEyhYYVrWCdknml3t4gkSe9Nagd1o9Royy8zL3XEAmRzeHS00xAKTfgpi + +COINFLOW_MERCHANT_ID=audius diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 5b172716f5a..4f3c4c57b9a 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -485,7 +485,7 @@ PODS: - react-native-flipper-performance-plugin/FBDefines (= 0.3.1) - react-native-flipper-performance-plugin/FBDefines (0.3.1): - React-Core - - react-native-get-random-values (1.8.0): + - react-native-get-random-values (1.10.0): - React-Core - react-native-google-cast (4.6.0): - React @@ -1157,7 +1157,7 @@ SPEC CHECKSUMS: react-native-fast-crypto: 5943c42466b86ad70be60d3a5f64bd22251e5d9e react-native-flipper: fd500c92e89502a8a0e5a87b4bfe8da751ccd435 react-native-flipper-performance-plugin: 2b873b68da3e368afeaf29c9c7a8c2b0ff908c4f - react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a + react-native-get-random-values: 384787fd76976f5aec9465aff6fa9e9129af1e74 react-native-google-cast: bbbdbeae552d0b55b59031ed7e12ae832e8c5a53 react-native-image-picker: bf34f3f516d139ed3e24c5f5a381a91819e349ea react-native-in-app-review: a073f67c5f3392af6ea7fb383217cdb1aa2aa726 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index e9b5cdb3c7b..deae07e6c0c 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -92,6 +92,7 @@ "@stripe/stripe-js": "1.54.1", "@tanstack/react-query": "4.35.7", "@walletconnect/react-native-dapp": "1.8.0", + "@coinflowlabs/react-native": "2.1.1", "array.prototype.flat": "1.2.5", "big-integer": "1.6.51", "bn.js": "5.2.0", @@ -128,7 +129,7 @@ "react-native-fast-image": "8.6.3", "react-native-fs": "2.18.0", "react-native-gesture-handler": "1.10.3", - "react-native-get-random-values": "1.8.0", + "react-native-get-random-values": "1.10.0", "react-native-google-cast": "4.6.0", "react-native-haptic-feedback": "1.11.0", "react-native-image-crop-picker": "0.40.0", diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index b9d485acafa..fc4c3ec2117 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -10,6 +10,7 @@ import { TiersExplainerDrawer } from 'app/components/audio-rewards' import { BlockMessagesDrawer } from 'app/components/block-messages-drawer' import { ChallengeRewardsDrawer } from 'app/components/challenge-rewards-drawer' import { ChatActionsDrawer } from 'app/components/chat-actions-drawer' +import { CoinflowOnrampDrawer } from 'app/components/coinflow-onramp-drawer/CoinflowOnrampDrawer' import { CollectibleDetailsDrawer } from 'app/components/collectible-details-drawer' import { CreateChatActionsDrawer } from 'app/components/create-chat-actions-drawer' import { DeactivateAccountConfirmationDrawer } from 'app/components/deactivate-account-confirmation-drawer' @@ -117,6 +118,7 @@ const commonDrawersMap: { [Modal in Modals]?: ComponentType } = { // to avoid zIndex issues. PremiumContentPurchaseModal: PremiumTrackPurchaseDrawer, AddFundsModal: AddFundsDrawer, + CoinflowOnramp: CoinflowOnrampDrawer, PurchaseVendor: PurchaseVendorDrawer, USDCManualTransferModal: USDCManualTransferDrawer, StripeOnRamp: StripeOnrampDrawer, diff --git a/packages/mobile/src/components/add-funds-drawer/AddFundsDrawer.tsx b/packages/mobile/src/components/add-funds-drawer/AddFundsDrawer.tsx index 526e00a4995..ce3ea06c774 100644 --- a/packages/mobile/src/components/add-funds-drawer/AddFundsDrawer.tsx +++ b/packages/mobile/src/components/add-funds-drawer/AddFundsDrawer.tsx @@ -4,9 +4,7 @@ import { useAddFundsModal, useUSDCManualTransferModal, buyUSDCActions, - USDCOnRampProvider, PurchaseMethod, - PurchaseVendor, DEFAULT_PURCHASE_AMOUNT_CENTS } from '@audius/common' import { View } from 'react-native' @@ -52,18 +50,14 @@ export const AddFundsDrawer = () => { useState(PurchaseMethod.CARD) const openCardFlow = useCallback(() => { - switch (purchaseVendorState) { - case PurchaseVendor.STRIPE: - dispatch( - buyUSDCActions.onrampOpened({ - provider: USDCOnRampProvider.STRIPE, - purchaseInfo: { - desiredAmount: DEFAULT_PURCHASE_AMOUNT_CENTS - } - }) - ) - break - } + dispatch( + buyUSDCActions.onrampOpened({ + vendor: purchaseVendorState, + purchaseInfo: { + desiredAmount: DEFAULT_PURCHASE_AMOUNT_CENTS + } + }) + ) }, [dispatch, purchaseVendorState]) const onContinuePress = useCallback(() => { @@ -95,8 +89,8 @@ export const AddFundsDrawer = () => { diff --git a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.module.css b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.module.css new file mode 100644 index 00000000000..406b1c20bd8 --- /dev/null +++ b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.module.css @@ -0,0 +1,16 @@ +.modalWrapper { + height: 100%; +} + +.modalBody { + height: 100%; + max-width: 720px; + max-height: 800px; +} + +.modalWrapper iframe { + max-height: 100% !important; + min-height: 800px !important; + max-width: 100% !important; + margin: 0 auto !important; +} diff --git a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx new file mode 100644 index 00000000000..49840954796 --- /dev/null +++ b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useState } from 'react' + +import { + getRootSolanaAccount, + useAppContext, + useCoinflowOnrampModal, + coinflowModalUIActions +} from '@audius/common' +import { + CoinflowPurchase, + CoinflowSolanaPurchaseProps +} from '@coinflowlabs/react' +import { Connection, Transaction } from '@solana/web3.js' +import { useDispatch } from 'react-redux' + +import ModalDrawer from 'pages/audio-rewards-page/components/modals/ModalDrawer' +import zIndex from 'utils/zIndex' + +import styles from './CoinflowOnrampModal.module.css' + +const { transactionSucceeded } = coinflowModalUIActions + +const MERCHANT_ID = process.env.VITE_COINFLOW_MERCHANT_ID +const IS_PRODUCTION = process.env.VITE_ENVIRONMENT === 'production' + +type CoinflowAdapter = { + wallet: CoinflowSolanaPurchaseProps['wallet'] + connection: Connection +} + +const useCoinflowAdapter = () => { + const { audiusBackend } = useAppContext() + const [adapter, setAdapter] = useState(null) + + useEffect(() => { + const initWallet = async () => { + const libs = await audiusBackend.getAudiusLibsTyped() + if (!libs.solanaWeb3Manager) return + const { connection } = libs.solanaWeb3Manager + const wallet = await getRootSolanaAccount(audiusBackend) + setAdapter({ + connection, + wallet: { + publicKey: wallet.publicKey, + sendTransaction: async (transaction: Transaction) => { + transaction.partialSign(wallet) + const res = await connection.sendRawTransaction( + transaction.serialize() + ) + return res + } + } + }) + } + initWallet() + }, [audiusBackend]) + + return adapter +} + +export const CoinflowOnrampModal = () => { + const { + data: { amount, serializedTransaction }, + isOpen, + onClose, + onClosed + } = useCoinflowOnrampModal() + const dispatch = useDispatch() + const [transaction, setTransaction] = useState( + undefined + ) + + const adapter = useCoinflowAdapter() + + useEffect(() => { + if (serializedTransaction) { + try { + const deserialized = Transaction.from( + Buffer.from(serializedTransaction, 'base64') + ) + setTransaction(deserialized) + } catch (e) { + console.error(e) + } + } + }, [serializedTransaction]) + + const handleSuccess = useCallback(() => { + dispatch(transactionSucceeded({})) + }, [dispatch]) + + const showContent = isOpen && adapter + + return ( + + {showContent ? ( + + ) : null} + + ) +} diff --git a/packages/web/src/components/coinflow-onramp-modal/index.ts b/packages/web/src/components/coinflow-onramp-modal/index.ts new file mode 100644 index 00000000000..b2673b5cc43 --- /dev/null +++ b/packages/web/src/components/coinflow-onramp-modal/index.ts @@ -0,0 +1 @@ +export { CoinflowOnrampModal as default } from './CoinflowOnrampModal' diff --git a/packages/web/src/components/drawer/Drawer.tsx b/packages/web/src/components/drawer/Drawer.tsx index a5b63bd0211..0cd8cc4f76f 100644 --- a/packages/web/src/components/drawer/Drawer.tsx +++ b/packages/web/src/components/drawer/Drawer.tsx @@ -331,6 +331,7 @@ const interpolateBorderRadius = (r: number) => { const FullscreenDrawer = ({ children, isOpen, + zIndex, onClose, onClosed, 'aria-labelledby': ariaLabelledBy @@ -391,7 +392,8 @@ const FullscreenDrawer = ({ borderRadius: props.borderRadius?.interpolate( // @ts-ignore interpolateBorderRadius - ) + ), + zIndex }} >
diff --git a/packages/web/src/components/mobile-filter-button/MobileFilterButton.tsx b/packages/web/src/components/mobile-filter-button/MobileFilterButton.tsx index 7998e30607c..8a2f54b6ef8 100644 --- a/packages/web/src/components/mobile-filter-button/MobileFilterButton.tsx +++ b/packages/web/src/components/mobile-filter-button/MobileFilterButton.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' -import { PurchaseVendor } from '@audius/common' import { Box, Flex, IconCaretDown, Text } from '@audius/harmony' import ActionDrawer from 'components/action-drawer/ActionDrawer' @@ -9,6 +8,7 @@ type MobileFilterButtonTypes = { options: { label: string }[] onClose?: () => void onSelect?: (label: string) => void + initialSelectionIndex?: number zIndex?: number } @@ -16,13 +16,24 @@ export const MobileFilterButton = ({ options, onClose, onSelect, + initialSelectionIndex, zIndex }: MobileFilterButtonTypes) => { const [isOpen, setIsOpen] = useState(false) + const [selection, setSelection] = useState( + initialSelectionIndex !== undefined ? options[initialSelectionIndex] : null + ) + useEffect(() => { + if (selection && onSelect) { + onSelect(selection.label) + } + }, [selection, onSelect]) + const actions = options.map((option) => ({ text: option.label, onClick: () => { - onSelect?.(option.label) + setIsOpen(false) + setSelection(option) } })) return ( @@ -41,7 +52,7 @@ export const MobileFilterButton = ({ onClick={() => setIsOpen((open) => !open)} > - {PurchaseVendor.STRIPE} + {selection?.label} diff --git a/packages/web/src/components/payment-method/PaymentMethod.tsx b/packages/web/src/components/payment-method/PaymentMethod.tsx index fa1e387374d..36d4f19843b 100644 --- a/packages/web/src/components/payment-method/PaymentMethod.tsx +++ b/packages/web/src/components/payment-method/PaymentMethod.tsx @@ -2,11 +2,13 @@ import { CSSProperties, ChangeEvent, useCallback } from 'react' import { BNUSDC, + FeatureFlags, Nullable, PurchaseMethod, PurchaseVendor, formatCurrencyBalance, - formatUSDCWeiToFloorCentsNumber + formatUSDCWeiToFloorCentsNumber, + useFeatureFlag } from '@audius/common' import { FilterButton, @@ -33,26 +35,41 @@ const messages = { } type PaymentMethodProps = { - selectedType: Nullable - setSelectedType: (method: PurchaseMethod) => void + selectedMethod: Nullable + setSelectedMethod: (method: PurchaseMethod) => void + setSelectedVendor: (vendor: PurchaseVendor) => void balance?: Nullable isExistingBalanceDisabled?: boolean showExistingBalance?: boolean } export const PaymentMethod = ({ - selectedType, - setSelectedType, + selectedMethod, + setSelectedMethod, + setSelectedVendor, balance, isExistingBalanceDisabled, showExistingBalance }: PaymentMethodProps) => { + const { isEnabled: isCoinflowEnabled } = useFeatureFlag( + FeatureFlags.BUY_WITH_COINFLOW + ) const mobile = isMobile() const balanceCents = formatUSDCWeiToFloorCentsNumber( (balance ?? new BN(0)) as BNUSDC ) const balanceFormatted = formatCurrencyBalance(balanceCents / 100) - const vendorOptions = [{ label: PurchaseVendor.STRIPE }] + const vendorOptions = [ + ...(isCoinflowEnabled ? [{ label: PurchaseVendor.COINFLOW }] : []), + { label: PurchaseVendor.STRIPE } + ] + + const handleSelectVendor = useCallback( + (label: string) => { + setSelectedVendor(label as PurchaseVendor) + }, + [setSelectedVendor] + ) const options = [ showExistingBalance @@ -66,7 +83,7 @@ export const PaymentMethod = ({ as='span' // Needed to avoid

inside

warning variant='title' color={ - selectedType === PurchaseMethod.BALANCE + selectedMethod === PurchaseMethod.BALANCE ? 'secondary' : undefined } @@ -84,13 +101,14 @@ export const PaymentMethod = ({ vendorOptions.length > 1 ? ( mobile ? ( {}} + onSelect={handleSelectVendor} + initialSelectionIndex={0} options={vendorOptions} zIndex={zIndex.ADD_FUNDS_VENDOR_SELECTION_DRAWER} /> ) : ( {}} + onSelect={handleSelectVendor} initialSelectionIndex={0} variant={FilterButtonType.REPLACE_LABEL} options={vendorOptions} @@ -108,9 +126,9 @@ export const PaymentMethod = ({ const handleRadioChange = useCallback( (e: ChangeEvent) => { - setSelectedType(e.target.value as PurchaseMethod) + setSelectedMethod(e.target.value as PurchaseMethod) }, - [setSelectedType] + [setSelectedMethod] ) const renderBody = () => { @@ -133,7 +151,7 @@ export const PaymentMethod = ({ return ( @@ -147,7 +165,7 @@ export const PaymentMethod = ({ borderTop='default' > setSelectedType(id as PurchaseMethod)} + onClick={() => setSelectedMethod(id as PurchaseMethod)} css={{ cursor: 'pointer' }} alignItems='center' justifyContent='space-between' diff --git a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx index ee001980683..d30237a9dca 100644 --- a/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx +++ b/packages/web/src/components/premium-content-purchase-modal/components/PurchaseContentFormFields.tsx @@ -1,9 +1,14 @@ +import { useCallback } from 'react' + import { PurchaseContentStage, usePayExtraPresets, useUSDCBalance, PURCHASE_METHOD, - usePurchaseMethod + PurchaseVendor, + PURCHASE_VENDOR, + usePurchaseMethod, + PurchaseMethod } from '@audius/common' import { Flex } from '@audius/harmony' import { IconCheck } from '@audius/stems' @@ -39,6 +44,7 @@ export const PurchaseContentFormFields = ({ const payExtraAmountPresetValues = usePayExtraPresets() const [{ value: purchaseMethod }, , { setValue: setPurchaseMethod }] = useField(PURCHASE_METHOD) + const [, , { setValue: setPurchaseVendor }] = useField(PURCHASE_VENDOR) const isPurchased = stage === PurchaseContentStage.FINISH const { data: balanceBN } = useUSDCBalance({ isPolling: true }) @@ -53,6 +59,20 @@ export const PurchaseContentFormFields = ({ setMethod: setPurchaseMethod }) + const handleChangeMethod = useCallback( + (method: string) => { + setPurchaseMethod(method as PurchaseMethod) + }, + [setPurchaseMethod] + ) + + const handleChangeVendor = useCallback( + (vendor: string) => { + setPurchaseVendor(vendor as PurchaseVendor) + }, + [setPurchaseVendor] + ) + if (isPurchased) { return ( @@ -80,11 +100,12 @@ export const PurchaseContentFormFields = ({ /> {isUnlocking || isPurchased ? null : ( )} {isUnlocking ? null : } diff --git a/packages/web/src/pages/modals/Modals.tsx b/packages/web/src/pages/modals/Modals.tsx index 86621277376..bc78d721131 100644 --- a/packages/web/src/pages/modals/Modals.tsx +++ b/packages/web/src/pages/modals/Modals.tsx @@ -10,6 +10,7 @@ import AppCTAModal from 'components/app-cta-modal/AppCTAModal' import BrowserPushConfirmationModal from 'components/browser-push-confirmation-modal/BrowserPushConfirmationModal' import { BuyAudioModal } from 'components/buy-audio-modal/BuyAudioModal' import { BuyAudioRecoveryModal } from 'components/buy-audio-modal/BuyAudioRecoveryModal' +import CoinflowOnrampModal from 'components/coinflow-onramp-modal' import CollectibleDetailsModal from 'components/collectibles/components/CollectibleDetailsModal' import ConfirmerPreview from 'components/confirmer-preview/ConfirmerPreview' import DeletePlaylistConfirmationModal from 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' @@ -102,6 +103,7 @@ const commonModalsMap: { [Modal in ModalTypes]?: ComponentType } = { CreateChatModal, InboxUnavailableModal, WithdrawUSDCModal, + CoinflowOnramp: CoinflowOnrampModal, StripeOnRamp: StripeOnRampModal, USDCPurchaseDetailsModal, USDCTransactionDetailsModal, diff --git a/packages/web/src/utils/zIndex.ts b/packages/web/src/utils/zIndex.ts index 8937fd2f946..51c3654c81f 100644 --- a/packages/web/src/utils/zIndex.ts +++ b/packages/web/src/utils/zIndex.ts @@ -38,6 +38,7 @@ export enum zIndex { ADD_FUNDS_VENDOR_SELECTION_DRAWER = 10004, USDC_MANUAL_TRANSFER_MODAL = 10004, USDC_ADD_FUNDS_FILTER_BUTTON_POPUP = 10005, + COINFLOW_ONRAMP_MODAL = 10006, ARTIST_POPOVER_POPUP = 20000, PLAY_BAR_POPUP_MENU = 20001, FEATURE_FLAG_OVERRIDE_MODAL = 30000,