From 0a7ce8fab96c7211d52e1c482b647cdff0cb870f Mon Sep 17 00:00:00 2001 From: Onno Visser Date: Wed, 31 May 2023 12:13:06 +0200 Subject: [PATCH] Centrifuge App: Operational Security for Pools (#1313) --- .github/README.md | 64 ++- centrifuge-app/package.json | 2 +- centrifuge-app/src/components/BuyDialog.tsx | 11 +- .../src/components/DebugFlags/config.ts | 12 +- .../Dialogs/CreateCollectionDialog.tsx | 17 +- .../Dialogs/RemoveListingDialog.tsx | 13 +- .../src/components/Dialogs/SellDialog.tsx | 13 +- .../Dialogs/ShareMultisigDialog.tsx | 66 +++ .../src/components/Dialogs/TransferDialog.tsx | 11 +- centrifuge-app/src/components/EpochList.tsx | 3 +- centrifuge-app/src/components/Identity.tsx | 33 +- .../InvestRedeemCentrifugeProvider.tsx | 28 +- .../src/components/LiquidityEpochSection.tsx | 37 +- .../src/components/MaxReserveForm.tsx | 13 +- .../src/components/Menu/PoolLink.tsx | 1 - centrifuge-app/src/components/Menu/index.tsx | 20 +- .../src/components/OnboardingAuthProvider.tsx | 3 +- centrifuge-app/src/components/PageSection.tsx | 10 +- .../src/components/PendingMultisigs/index.tsx | 197 +++++++ .../src/components/PodAuthProvider.tsx | 154 ------ .../src/components/PodAuthSection.tsx | 14 +- centrifuge-app/src/components/Root.tsx | 51 +- .../pages/IssuerCreatePool/AdminMultisig.tsx | 43 ++ .../src/pages/IssuerCreatePool/index.tsx | 225 ++++++-- .../IssuerPool/Access/AssetOriginators.tsx | 413 ++++++++++++++ .../pages/IssuerPool/Access/MultisigForm.tsx | 117 ++++ .../pages/IssuerPool/Access/PoolManagers.tsx | 169 ++++++ .../src/pages/IssuerPool/Access/index.tsx | 40 ++ .../pages/IssuerPool/Assets/CreateLoan.tsx | 62 ++- .../src/pages/IssuerPool/Assets/index.tsx | 27 +- .../Configuration/AddAddressInput.tsx | 68 +++ .../pages/IssuerPool/Configuration/Admins.tsx | 105 +--- .../Configuration/CreateLoanTemplate.tsx | 8 +- .../IssuerPool/Configuration/Details.tsx | 6 +- .../Configuration/EpochAndTranches.tsx | 6 +- .../pages/IssuerPool/Configuration/Issuer.tsx | 6 +- .../Configuration/LoanTemplates.tsx | 3 +- .../IssuerPool/Configuration/PoolConfig.tsx | 8 +- .../Configuration/WriteOffGroups.tsx | 6 +- .../pages/IssuerPool/Configuration/index.tsx | 16 +- .../src/pages/IssuerPool/Header.tsx | 48 +- .../IssuerPool/Investors/InvestorStatus.tsx | 11 +- .../Investors/OnboardingSettings.tsx | 4 +- .../src/pages/IssuerPool/Investors/index.tsx | 17 +- .../src/pages/IssuerPool/Liquidity/index.tsx | 13 +- .../src/pages/IssuerPool/Overview/index.tsx | 8 +- centrifuge-app/src/pages/IssuerPool/index.tsx | 2 + centrifuge-app/src/pages/Loan/FinanceForm.tsx | 16 +- centrifuge-app/src/pages/Loan/index.tsx | 23 +- centrifuge-app/src/pages/MintNFT.tsx | 11 +- centrifuge-app/src/pages/MultisigApproval.tsx | 98 ++++ .../pages/Onboarding/queries/useSignRemark.ts | 3 +- .../src/pages/Pool/Liquidity/index.tsx | 1 - .../src/utils/tinlake/useTinlakePools.ts | 1 - centrifuge-app/src/utils/useBalance.ts | 24 - ...roposalEstimate.ts => useCreatePoolFee.ts} | 58 +- .../src/utils/useFocusInvalidInput.ts | 2 +- centrifuge-app/src/utils/useIdentity.ts | 41 +- centrifuge-app/src/utils/usePermissions.ts | 262 ++++++++- centrifuge-app/src/utils/usePod.ts | 21 - centrifuge-app/src/utils/usePodAuth.ts | 92 ++++ centrifuge-app/src/utils/usePodDocument.ts | 14 +- centrifuge-app/src/utils/usePools.ts | 26 +- centrifuge-app/src/utils/validation/index.ts | 4 + centrifuge-js/package.json | 6 +- centrifuge-js/src/Centrifuge.ts | 2 + centrifuge-js/src/CentrifugeBase.ts | 128 ++++- centrifuge-js/src/index.ts | 1 + centrifuge-js/src/modules/auth.ts | 4 +- centrifuge-js/src/modules/metadata.ts | 8 +- centrifuge-js/src/modules/multisig.ts | 233 ++++++++ centrifuge-js/src/modules/nfts.ts | 18 +- centrifuge-js/src/modules/pools.ts | 142 ++--- centrifuge-js/src/modules/proxies.ts | 88 ++- centrifuge-js/src/types/index.ts | 12 + centrifuge-js/src/utils/BN.ts | 11 +- centrifuge-js/src/utils/index.ts | 22 +- centrifuge-js/src/utils/parseCall.ts | 112 ++++ centrifuge-react/package.json | 2 +- .../CentrifugeProvider/CentrifugeProvider.tsx | 50 +- .../components/CentrifugeProvider/index.tsx | 9 +- .../src/components/WalletMenu/WalletMenu.tsx | 2 +- .../WalletProvider/AccountButton.tsx | 48 +- .../WalletProvider/WalletDialog.tsx | 79 +-- .../WalletProvider/WalletProvider.tsx | 166 ++++-- .../src/components/WalletProvider/index.tsx | 1 + .../src/components/WalletProvider/types.ts | 22 +- .../WalletProvider/useWalletState.ts | 62 ++- .../src/components/WalletProvider/utils.ts | 3 +- .../src/hooks/useCentrifugeQueries.ts | 74 +++ .../src/hooks/useCentrifugeQuery.ts | 59 +- .../src/hooks/useCentrifugeTransaction.ts | 48 +- centrifuge-react/src/index.ts | 1 + fabric/package.json | 2 +- fabric/src/components/Select/index.tsx | 13 +- package.json | 6 +- yarn.lock | 507 +++--------------- 97 files changed, 3408 insertions(+), 1434 deletions(-) create mode 100644 centrifuge-app/src/components/Dialogs/ShareMultisigDialog.tsx create mode 100644 centrifuge-app/src/components/PendingMultisigs/index.tsx delete mode 100644 centrifuge-app/src/components/PodAuthProvider.tsx create mode 100644 centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Access/MultisigForm.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Access/PoolManagers.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Access/index.tsx create mode 100644 centrifuge-app/src/pages/IssuerPool/Configuration/AddAddressInput.tsx create mode 100644 centrifuge-app/src/pages/MultisigApproval.tsx delete mode 100644 centrifuge-app/src/utils/useBalance.ts rename centrifuge-app/src/utils/{useProposalEstimate.ts => useCreatePoolFee.ts} (72%) delete mode 100644 centrifuge-app/src/utils/usePod.ts create mode 100644 centrifuge-app/src/utils/usePodAuth.ts create mode 100644 centrifuge-js/src/modules/multisig.ts create mode 100644 centrifuge-js/src/utils/parseCall.ts create mode 100644 centrifuge-react/src/hooks/useCentrifugeQueries.ts diff --git a/.github/README.md b/.github/README.md index cba4bab241..da6ea14c63 100644 --- a/.github/README.md +++ b/.github/README.md @@ -16,7 +16,6 @@ It's also recommended to run Prettier automatically in your editor, e.g. using [ ### Frontend 1. Create pools for initial data -2. Create proxy for POD authentication ### Faucet @@ -38,6 +37,69 @@ It's also recommended to run Prettier automatically in your editor, e.g. using [ d. Fund both the proxy and the controlling account e. In each pool give the pure proxy whitelisting permission - this can only be done by the pool admin +### Asset Originator POD Access + +When setting up an Asset Originator for a pool, the account on the POD needs to be manually created + +1. Create AO on the Access tab of the Issuers Pool page +2. Copy the address of the newly created AO proxy +3. Get a jw3t auth token. Needs to be signed as Eve on behalf of Eve with proxy type `PodAdmin`. Example token: `ewogImFsZ29yaXRobSI6ICJzcjI1NTE5IiwKICJ0b2tlbl90eXBlIjogIkpXM1QiLAogImFkZHJlc3NfdHlwZSI6ICJzczU4Igp9.ewogImFkZHJlc3MiOiAiNUhHaldBZUZEZkZDV1BzakZRZFZWMk1zdnoyWHRNa3R2Z29jRVpjQ2o2OGtVTWF3IiwKICJpc3N1ZWRfYXQiOiAiMTY4MTk5Mzc4MCIsCiAiZXhwaXJlc19hdCI6ICIxOTk3MzUzNzgwIiwKICJvbl9iZWhhbGZfb2YiOiAiNUhHaldBZUZEZkZDV1BzakZRZFZWMk1zdnoyWHRNa3R2Z29jRVpjQ2o2OGtVTWF3IiwKICJub3RfYmVmb3JlIjogIjE2ODE5OTM3ODAiLAogInByb3h5X3R5cGUiOiAiUG9kQWRtaW4iCn0.-BJ7Y6WurKYwesCMfkTrudsH5ZVseMviVNdZ0kFZmEnAtAYvdxqxN56aVwRR5QvEjK8Of4TVtY_-oPK4hP7Dhg` + +A token can be created with the code below: + +```js +const keyring = new Keyring({ type: 'sr25519' }) +const EveKeyRing = keyring.addFromUri('//Eve') + +const centrifuge = new Centrifuge({ + polkadotWsUrl: 'wss://fullnode-relay.development.cntrfg.com', + centrifugeWsUrl: 'wss://fullnode.development.cntrfg.com', + signingAddress: AliceKeyRing, +}) + +const token = await centrifuge.auth.generateJw3t(EveKeyRing, undefined, { + onBehalfOf: EveKeyRing.address, + proxyType: 'PodAdmin', + expiresAt: String(Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10), // 10 years +}) +``` + +`PodAdmin` is a special proxy type that only exists on the POD and not on-chain. + +4. Call `https://pod.development.cntrfg.com/v2/accounts/generate` (More details about the request here: https://app.swaggerhub.com/apis/centrifuge.io/cent-node/2.1.0#/Accounts/generate_account_v2) with the token in the Authorization header. Example below: + +```bash +curl --request POST \ + --url https://pod.development.cntrfg.com/v2/accounts/generate \ + --header 'Authorization: Bearer ewogImFsZ29yaXRobSI6ICJzcjI1NTE5IiwKICJ0b2tlbl90eXBlIjogIkpXM1QiLAogImFkZHJlc3NfdHlwZSI6ICJzczU4Igp9.ewogImFkZHJlc3MiOiAiNUhHaldBZUZEZkZDV1BzakZRZFZWMk1zdnoyWHRNa3R2Z29jRVpjQ2o2OGtVTWF3IiwKICJpc3N1ZWRfYXQiOiAiMTY4MTIwNjk4NCIsCiAiZXhwaXJlc19hdCI6ICIxNjgzNzk4OTg0IiwKICJvbl9iZWhhbGZfb2YiOiAiNUhHaldBZUZEZkZDV1BzakZRZFZWMk1zdnoyWHRNa3R2Z29jRVpjQ2o2OGtVTWF3IiwKICJub3RfYmVmb3JlIjogIjE2ODEyMDY5ODQiLAogInByb3h5X3R5cGUiOiAiUG9kQWRtaW4iCn0.oLovvmVzXJRz-eY1V0wHFNdF6HnVa1unx684xEoMhgBOdCyV8I4yZvUjMx4qLK1vj9Oeh42dAmJ5_vAti9D4jQ' \ + --header 'Content-Type: application/json' \ + --data '{ + "account": { + "identity": "0x3fe43572af486a48cf27e038fd42a2657cd8495c5f4f1a5553833135eb75b316", + "precommit_enabled": true, + "webhook_url": "https://centrifuge.io" + } +}' +``` + +`identity` is the hex formatted address for the account you want to create. +The response will look something like: + +```json +{ + "identity": "0x3fe43572af486a48cf27e038fd42a2657cd8495c5f4f1a5553833135eb75b316", + "webhook_url": "https://centrifuge.io", + "precommit_enabled": true, + "document_signing_public_key": "0x85d46bae1577ead77f00931fc63618e2587486d8c95dc7fc8637a63fde0668ed", + "p2p_public_signing_key": "0xafed109165d041b83f2a42a8863a28277e0fa35e900e9544d0c46e2e2772b488", + "pod_operator_account_id": "0x1cbd2d43530a44705ad088af313e18f80b53ef16b36177cd4b77b846f2a5f07c" +} +``` + +5. Copy `document_signing_public_key`, `p2p_public_signing_key` and `pod_operator_account_id` of the returned result and paste those in the AO section on the Access tab +6. Add hot wallets to the AO and submit the form +7. If successful, the hot wallets should now be able to authenticate with the POD and be able to create assets. + ## Notes To add other repositories to this monorepo while preserving the Git history, we can use the following steps: https://medium.com/@filipenevola/how-to-migrate-to-mono-repository-without-losing-any-git-history-7a4d80aa7de2 diff --git a/centrifuge-app/package.json b/centrifuge-app/package.json index 7690b4445a..42556f5a8c 100644 --- a/centrifuge-app/package.json +++ b/centrifuge-app/package.json @@ -31,7 +31,7 @@ "@ethersproject/units": "^5.6.0", "@makerdao/multicall": "^0.12.0", "@polkadot/extension-dapp": "^0.44.1", - "@polkadot/react-identicon": "^2.8.2", + "@polkadot/react-identicon": "^3.1.4", "@stablelib/blake2b": "^1.0.1", "@styled-system/css": "^5.1.5", "@styled-system/should-forward-prop": "^5.1.5", diff --git a/centrifuge-app/src/components/BuyDialog.tsx b/centrifuge-app/src/components/BuyDialog.tsx index 783f376c51..e6435632ba 100644 --- a/centrifuge-app/src/components/BuyDialog.tsx +++ b/centrifuge-app/src/components/BuyDialog.tsx @@ -1,10 +1,9 @@ -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' import { Button, Dialog, Shelf, Stack, Text } from '@centrifuge/fabric' import BN from 'bn.js' import * as React from 'react' import { Dec } from '../utils/Decimal' import { useAddress } from '../utils/useAddress' -import { useBalance } from '../utils/useBalance' import { useCentNFT } from '../utils/useNFTs' import { ButtonGroup } from './ButtonGroup' @@ -19,7 +18,7 @@ const TRANSFER_FEE_ESTIMATE = 0.1 export const BuyDialog: React.FC = ({ open, onClose, collectionId, nftId }) => { const address = useAddress('substrate') - const balance = useBalance() + const balances = useBalances(address) const nft = useCentNFT(collectionId, nftId) const isConnected = !!address @@ -55,14 +54,14 @@ export const BuyDialog: React.FC = ({ open, onClose, collectionId, nftId } const priceDec = Dec(nft?.sellPrice ?? 0).div('1e18') - const balanceDec = Dec(balance ?? 0) + const balanceDec = balances?.native.balance.toDecimal() ?? Dec(0) const balanceLow = balanceDec.lt(priceDec.add(Dec(TRANSFER_FEE_ESTIMATE))) const disabled = balanceLow || !nft function getMessage() { - if (balance == null) return + if (balances == null) return if (balanceDec.lt(priceDec)) return 'Insufficient funds to purchase this NFT' if (balanceLow) return 'Insufficient funds to pay for transaction costs' } @@ -82,7 +81,7 @@ export const BuyDialog: React.FC = ({ open, onClose, collectionId, nftId {nft?.sellPrice && `${formatPrice(priceDec.toNumber())} AIR`} - {balance != null && {formatPrice(balance)} AIR balance} + {balances != null && {formatPrice(balanceDec.toNumber())} AIR balance} diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts index f51da8e513..04dac3d1a0 100644 --- a/centrifuge-app/src/components/DebugFlags/config.ts +++ b/centrifuge-app/src/components/DebugFlags/config.ts @@ -30,6 +30,8 @@ export type Key = | 'editPoolConfig' | 'poolReporting' | 'editPoolVisibility' + | 'showAdvancedAccounts' + | 'editAdminConfig' export const flagsConfig: Record = { address: { @@ -52,12 +54,14 @@ export const flagsConfig: Record = { editPoolConfig: { type: 'checkbox', default: false, - alwaysShow: true, + }, + editAdminConfig: { + type: 'checkbox', + default: false, }, poolReporting: { type: 'checkbox', default: false, - alwaysShow: true, }, persistDebugFlags: { type: 'checkbox', @@ -71,6 +75,10 @@ export const flagsConfig: Record = { editPoolVisibility: { type: 'checkbox', default: false, + }, + showAdvancedAccounts: { + type: 'checkbox', + default: false, alwaysShow: true, }, } diff --git a/centrifuge-app/src/components/Dialogs/CreateCollectionDialog.tsx b/centrifuge-app/src/components/Dialogs/CreateCollectionDialog.tsx index ac0f19cfbf..a5949cbda9 100644 --- a/centrifuge-app/src/components/Dialogs/CreateCollectionDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/CreateCollectionDialog.tsx @@ -2,6 +2,7 @@ import { CollectionMetadataInput } from '@centrifuge/centrifuge-js/dist/modules/ import { ConnectionGuard, useAsyncCallback, + useBalances, useCentrifuge, useCentrifugeTransaction, useWallet, @@ -22,8 +23,9 @@ import * as React from 'react' import { Redirect } from 'react-router' import { lastValueFrom } from 'rxjs' import { collectionMetadataSchema } from '../../schemas' +import { Dec } from '../../utils/Decimal' import { getFileDataURI } from '../../utils/getFileDataURI' -import { useBalance } from '../../utils/useBalance' +import { useAddress } from '../../utils/useAddress' import { ButtonGroup } from '../ButtonGroup' // TODO: replace with better fee estimate @@ -38,7 +40,8 @@ export const CreateCollectionDialog: React.FC<{ open: boolean; onClose: () => vo const [description, setDescription] = React.useState('') const [logo, setLogo] = React.useState(null) const cent = useCentrifuge() - const balance = useBalance() + const address = useAddress('substrate') + const balances = useBalances(address) const [redirect, setRedirect] = React.useState('') const [confirmOpen, setConfirmOpen] = React.useState(false) const [termsAccepted, setTermsAccepted] = React.useState(false) @@ -70,15 +73,16 @@ export const CreateCollectionDialog: React.FC<{ open: boolean; onClose: () => vo const collectionId = await cent.nfts.getAvailableCollectionId() let fileDataUri + let imageMetadataHash if (logo) { fileDataUri = await getFileDataURI(logo) + imageMetadataHash = await lastValueFrom(cent.metadata.pinFile(fileDataUri)) } - const imageMetadataHash = await lastValueFrom(cent.metadata.pinFile(fileDataUri)) const metadataValues: CollectionMetadataInput = { name: nameValue, description: descriptionValue, - image: imageMetadataHash.ipfsHash, + image: imageMetadataHash?.ipfsHash, } doTransaction([collectionId, substrate.selectedAccount!.address, metadataValues]) @@ -106,7 +110,8 @@ export const CreateCollectionDialog: React.FC<{ open: boolean; onClose: () => vo onClose() } - const balanceLow = !balance || balance < CREATE_FEE_ESTIMATE + const balanceDec = balances?.native.balance.toDecimal() ?? Dec(0) + const balanceLow = balanceDec.lt(CREATE_FEE_ESTIMATE) const isTxPending = metadataIsUploading || transactionIsPending const fieldDisabled = !isConnected || balanceLow || isTxPending @@ -156,7 +161,7 @@ export const CreateCollectionDialog: React.FC<{ open: boolean; onClose: () => vo {balanceLow && ( - Your balance is too low ({(balance || 0).toFixed(2)} AIR) + Your balance is too low ({(balanceDec || 0).toFixed(2)} AIR) )} diff --git a/centrifuge-app/src/components/Dialogs/RemoveListingDialog.tsx b/centrifuge-app/src/components/Dialogs/RemoveListingDialog.tsx index eed02610e8..b1734d0207 100644 --- a/centrifuge-app/src/components/Dialogs/RemoveListingDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/RemoveListingDialog.tsx @@ -1,7 +1,8 @@ -import { useCentrifuge, useCentrifugeTransaction, useWallet } from '@centrifuge/centrifuge-react' +import { useBalances, useCentrifuge, useCentrifugeTransaction, useWallet } from '@centrifuge/centrifuge-react' import { Button, Dialog, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' -import { useBalance } from '../../utils/useBalance' +import { Dec } from '../../utils/Decimal' +import { useAddress } from '../../utils/useAddress' import { useCentNFT } from '../../utils/useNFTs' import { ButtonGroup } from '../ButtonGroup' @@ -16,7 +17,8 @@ const TRANSFER_FEE_ESTIMATE = 0.1 export const RemoveListingDialog: React.FC = ({ open, onClose, collectionId, nftId }) => { const { substrate } = useWallet() - const balance = useBalance() + const address = useAddress('substrate') + const balances = useBalances(address) const centrifuge = useCentrifuge() const nft = useCentNFT(collectionId, nftId) @@ -52,7 +54,8 @@ export const RemoveListingDialog: React.FC = ({ open, onClose, collection onClose() } - const balanceLow = !balance || balance < TRANSFER_FEE_ESTIMATE + const balanceDec = balances?.native.balance.toDecimal() ?? Dec(0) + const balanceLow = balanceDec.lt(TRANSFER_FEE_ESTIMATE) const disabled = balanceLow @@ -73,7 +76,7 @@ export const RemoveListingDialog: React.FC = ({ open, onClose, collection {balanceLow && ( - Your balance is too low ({(balance || 0).toFixed(2)} AIR) + Your balance is too low ({(balanceDec || 0).toFixed(2)} AIR) )} diff --git a/centrifuge-app/src/components/Dialogs/SellDialog.tsx b/centrifuge-app/src/components/Dialogs/SellDialog.tsx index f52783a199..ce50ca4341 100644 --- a/centrifuge-app/src/components/Dialogs/SellDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/SellDialog.tsx @@ -1,8 +1,9 @@ -import { useCentrifugeTransaction, useWallet } from '@centrifuge/centrifuge-react' +import { useBalances, useCentrifugeTransaction, useWallet } from '@centrifuge/centrifuge-react' import { Button, CurrencyInput, Dialog, Shelf, Stack, Text } from '@centrifuge/fabric' import BN from 'bn.js' import * as React from 'react' -import { useBalance } from '../../utils/useBalance' +import { Dec } from '../../utils/Decimal' +import { useAddress } from '../../utils/useAddress' import { ButtonGroup } from '../ButtonGroup' const e18 = new BN(10).pow(new BN(18)) @@ -20,7 +21,8 @@ export const SellDialog: React.FC = ({ open, onClose, collectionId, nftId const [price, setPrice] = React.useState() const [touched, setTouched] = React.useState(false) const { substrate } = useWallet() - const balance = useBalance() + const address = useAddress('substrate') + const balances = useBalances(address) const isConnected = !!substrate.selectedAccount?.address @@ -66,7 +68,8 @@ export const SellDialog: React.FC = ({ open, onClose, collectionId, nftId const error = getError() - const balanceLow = !balance || balance < TRANSFER_FEE_ESTIMATE + const balanceDec = balances?.native.balance.toDecimal() ?? Dec(0) + const balanceLow = balanceDec.lt(TRANSFER_FEE_ESTIMATE) const disabled = !!error || balanceLow @@ -89,7 +92,7 @@ export const SellDialog: React.FC = ({ open, onClose, collectionId, nftId {balanceLow && ( - Your balance is too low ({(balance || 0).toFixed(2)} AIR) + Your balance is too low ({(balanceDec || 0).toFixed(2)} AIR) )} diff --git a/centrifuge-app/src/components/Dialogs/ShareMultisigDialog.tsx b/centrifuge-app/src/components/Dialogs/ShareMultisigDialog.tsx new file mode 100644 index 0000000000..cf1f0c43fe --- /dev/null +++ b/centrifuge-app/src/components/Dialogs/ShareMultisigDialog.tsx @@ -0,0 +1,66 @@ +import { Multisig } from '@centrifuge/centrifuge-js' +import { Button, Dialog, IconCopy, IconSend, Shelf, Stack, Text, TextInput } from '@centrifuge/fabric' +import { copyToClipboard } from '../../utils/copyToClipboard' +import { ButtonGroup } from '../ButtonGroup' + +export type ShareMultisigDialogProps = { + multisig: Multisig + hash: string + callData?: string + open: boolean + onClose: () => void +} + +export function ShareMultisigDialog({ multisig, hash, callData, open, onClose }: ShareMultisigDialogProps) { + const shareUrl = getMultisigShareUrl({ multisig, hash, callData }) + + const shareData: ShareData = { + title: 'Approve Transaction', + text: 'Approve a multisig transaction on the Centrifuge App', + url: shareUrl, + } + + return ( + + + Share the link below with the other multisig signers to finalize the transaction + + { + copyToClipboard(shareUrl) + e.target.select() + }} + value={shareUrl} + readOnly + /> + + + ) +} + +export function getMultisigShareUrl({ + multisig, + hash, + callData, +}: Pick) { + const params = new URLSearchParams({ + hash, + signers: multisig.signers.join(','), + threshold: multisig.threshold.toString(), + data: callData || '', + }) + const url = new URL(`/multisig-approval`, window.location.origin) + url.search = params as any + const shareUrl = url.toString() + return shareUrl +} diff --git a/centrifuge-app/src/components/Dialogs/TransferDialog.tsx b/centrifuge-app/src/components/Dialogs/TransferDialog.tsx index aa18bb2741..212b62f33d 100644 --- a/centrifuge-app/src/components/Dialogs/TransferDialog.tsx +++ b/centrifuge-app/src/components/Dialogs/TransferDialog.tsx @@ -1,9 +1,9 @@ -import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' import { Button, Dialog, Shelf, Stack, Text, TextInput } from '@centrifuge/fabric' import { isAddress } from '@polkadot/util-crypto' import * as React from 'react' +import { Dec } from '../../utils/Decimal' import { useAddress } from '../../utils/useAddress' -import { useBalance } from '../../utils/useBalance' import { isSameAddress } from '../../utils/web3' import { ButtonGroup } from '../ButtonGroup' @@ -20,7 +20,7 @@ export const TransferDialog: React.FC = ({ open, onClose, collectionId, n const [address, setAddress] = React.useState('') const [touched, setTouched] = React.useState(false) const connectedAddress = useAddress('substrate') - const balance = useBalance() + const balances = useBalances(connectedAddress) const isConnected = !!connectedAddress @@ -65,7 +65,8 @@ export const TransferDialog: React.FC = ({ open, onClose, collectionId, n const error = getError() - const balanceLow = !balance || balance < TRANSFER_FEE_ESTIMATE + const balanceDec = balances?.native.balance.toDecimal() ?? Dec(0) + const balanceLow = balanceDec.lt(TRANSFER_FEE_ESTIMATE) const disabled = !!error || balanceLow @@ -89,7 +90,7 @@ export const TransferDialog: React.FC = ({ open, onClose, collectionId, n {balanceLow && ( - Your balance is too low ({(balance || 0).toFixed(2)} AIR) + Your balance is too low ({(balanceDec || 0).toFixed(2)} AIR) )} diff --git a/centrifuge-app/src/components/EpochList.tsx b/centrifuge-app/src/components/EpochList.tsx index 5cdd1ede34..3f0c35c139 100644 --- a/centrifuge-app/src/components/EpochList.tsx +++ b/centrifuge-app/src/components/EpochList.tsx @@ -3,7 +3,6 @@ import { Stack, Text } from '@centrifuge/fabric' import Decimal from 'decimal.js-light' import * as React from 'react' import { useParams } from 'react-router' -import { useTheme } from 'styled-components' import { formatBalance } from '../utils/formatting' import { useLiquidity } from '../utils/useLiquidity' import { usePool } from '../utils/usePools' @@ -47,7 +46,7 @@ export const columns: Column[] = [ ] export const EpochList: React.FC = ({ pool }) => { - const theme = useTheme() + // const theme = useTheme() const { // sumOfExecutableInvestments, sumOfLockedInvestments, diff --git a/centrifuge-app/src/components/Identity.tsx b/centrifuge-app/src/components/Identity.tsx index f2a0a4bf0f..241832fc1b 100644 --- a/centrifuge-app/src/components/Identity.tsx +++ b/centrifuge-app/src/components/Identity.tsx @@ -1,7 +1,9 @@ import { isSameAddress } from '@centrifuge/centrifuge-js' import { useCentrifuge, useWallet } from '@centrifuge/centrifuge-react' -import { Text, TextProps } from '@centrifuge/fabric' +import { Flex, Shelf, Text, TextProps } from '@centrifuge/fabric' +import Identicon from '@polkadot/react-identicon' import * as React from 'react' +import styled from 'styled-components' import { copyToClipboard } from '../utils/copyToClipboard' import { useAddress } from '../utils/useAddress' import { useIdentity } from '../utils/useIdentity' @@ -9,12 +11,25 @@ import { truncate } from '../utils/web3' type Props = TextProps & { address: string + showIcon?: boolean clickToCopy?: boolean labelForConnectedAddress?: boolean | string } +const IdenticonWrapper = styled(Flex)({ + borderRadius: '50%', + overflow: 'hidden', + pointerEvents: 'none', +}) + // TODO: Fix for when connected with a proxy -export const Identity: React.FC = ({ address, clickToCopy, labelForConnectedAddress = true, ...textProps }) => { +export const Identity: React.FC = ({ + showIcon, + address, + clickToCopy, + labelForConnectedAddress = true, + ...textProps +}) => { const identity = useIdentity(address) const myAddress = useAddress('substrate') const cent = useCentrifuge() @@ -31,7 +46,7 @@ export const Identity: React.FC = ({ address, clickToCopy, labelForConnec ? selectedAccount?.name || display : labelForConnectedAddress - return ( + const label = ( = ({ address, clickToCopy, labelForConnec {isMe ? meLabel : display} ) + + if (showIcon) { + return ( + + + + + {label} + + ) + } + return label } diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx index a78b9c9487..b7dc4d8e8f 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeemCentrifugeProvider.tsx @@ -1,22 +1,24 @@ import { CurrencyBalance, findBalance, Pool } from '@centrifuge/centrifuge-js' import { useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { CentrifugeTransactionOptions } from '@centrifuge/centrifuge-react/dist/hooks/useCentrifugeTransaction' import BN from 'bn.js' import * as React from 'react' import { Dec } from '../../utils/Decimal' import { useAddress } from '../../utils/useAddress' -import { usePermissions } from '../../utils/usePermissions' +import { useSuitableAccounts } from '../../utils/usePermissions' import { usePendingCollect, usePool, usePoolMetadata } from '../../utils/usePools' import { InvestRedeemContext } from './InvestRedeemProvider' import { InvestRedeemAction, InvestRedeemActions, InvestRedeemProviderProps as Props, InvestRedeemState } from './types' export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: Props) { - const address = useAddress('substrate') + const [account] = useSuitableAccounts({ poolId, poolRole: [{ trancheInvestor: trancheId }], proxyType: ['Invest'] }) + const fallbackAddress = useAddress('substrate') + const address = account?.actingAddress || fallbackAddress const balances = useBalances(address) const order = usePendingCollect(poolId, trancheId, address) const pool = usePool(poolId) as Pool const [pendingAction, setPendingAction] = React.useState() - const permissions = usePermissions(address) - const isAllowedToInvest = !!permissions?.pools[poolId]?.tranches[trancheId] + const isAllowedToInvest = !!account const tranche = pool.tranches.find((t) => t.id === trancheId) const { data: metadata, isLoading: isMetadataLoading } = usePoolMetadata(pool) const trancheMeta = metadata?.tranches?.[trancheId] @@ -53,9 +55,13 @@ export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: } const pendingTransaction = pendingAction && txActions[pendingAction]?.lastCreatedTransaction - function doAction(name: InvestRedeemAction, fn: (arg: T) => any[]): (args?: T) => void { + function doAction( + name: InvestRedeemAction, + fn: (arg: T) => any[], + opt?: CentrifugeTransactionOptions + ): (args?: T) => void { return (args) => { - txActions[name]?.execute(fn(args!) as any) + txActions[name]?.execute(fn(args!) as any, opt) setPendingAction(name) } } @@ -72,7 +78,7 @@ export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: const state: InvestRedeemState = { poolId, trancheId, - isDataLoading: balances == null || order == null || permissions == null || isMetadataLoading, + isDataLoading: balances == null || order == null || isMetadataLoading, isAllowedToInvest, isPoolBusy: isCalculatingOrders, isFirstInvestment: order?.submittedAt === 0 && order.investCurrency.isZero(), @@ -109,13 +115,13 @@ export function InvestRedeemCentrifugeProvider({ poolId, trancheId, children }: } const actions: InvestRedeemActions = { - invest: doAction('invest', (newOrder: BN) => [poolId, trancheId, newOrder]), - redeem: doAction('redeem', (newOrder: BN) => [poolId, trancheId, newOrder]), + invest: doAction('invest', (newOrder: BN) => [poolId, trancheId, newOrder], { account, forceProxyType: 'Invest' }), + redeem: doAction('redeem', (newOrder: BN) => [poolId, trancheId, newOrder], { account, forceProxyType: 'Invest' }), collect: () => {}, approvePoolCurrency: () => {}, approveTrancheToken: () => {}, - cancelInvest: doAction('cancelInvest', () => [poolId, trancheId, new BN(0)]), - cancelRedeem: doAction('cancelRedeem', () => [poolId, trancheId, new BN(0)]), + cancelInvest: doAction('cancelInvest', () => [poolId, trancheId, new BN(0)], { account, forceProxyType: 'Invest' }), + cancelRedeem: doAction('cancelRedeem', () => [poolId, trancheId, new BN(0)], { account, forceProxyType: 'Invest' }), } const hooks = { diff --git a/centrifuge-app/src/components/LiquidityEpochSection.tsx b/centrifuge-app/src/components/LiquidityEpochSection.tsx index 3310dfe24f..4c21f176d7 100644 --- a/centrifuge-app/src/components/LiquidityEpochSection.tsx +++ b/centrifuge-app/src/components/LiquidityEpochSection.tsx @@ -10,6 +10,7 @@ import { useTinlakeTransaction } from '../utils/tinlake/useTinlakeTransaction' import { useChallengeTimeCountdown } from '../utils/useChallengeTimeCountdown' import { useEpochTimeCountdown } from '../utils/useEpochTimeCountdown' import { useLiquidity } from '../utils/useLiquidity' +import { useSuitableAccounts } from '../utils/usePermissions' import { DataTable } from './DataTable' import { DataTableGroup } from './DataTableGroup' import { columns, EpochList, LiquidityTableRow } from './EpochList' @@ -52,9 +53,14 @@ export function LiquidityEpochSection({ pool }: LiquidityEpochSectionProps) { } function EpochStatusOngoing({ pool }: { pool: Pool }) { - const { sumOfLockedInvestments, sumOfLockedRedemptions, sumOfExecutableInvestments, sumOfExecutableRedemptions } = - useLiquidity(pool.id) + const { + sumOfLockedInvestments, + sumOfLockedRedemptions, + // sumOfExecutableInvestments, + // sumOfExecutableRedemptions + } = useLiquidity(pool.id) const { message: epochTimeRemaining } = useEpochTimeCountdown(pool.id) + const [account] = useSuitableAccounts({ poolId: pool.id, proxyType: ['Borrow', 'Invest'] }) const { execute: closeEpochTx, isLoading: loadingClose } = useCentrifugeTransaction( 'Start order execution', (cent) => cent.pools.closeEpoch, @@ -68,18 +74,21 @@ function EpochStatusOngoing({ pool }: { pool: Pool }) { const closeEpoch = async () => { if (!pool) return // const batchCloseAndSolution = ordersLocked && !ordersFullyExecutable - closeEpochTx([pool.id, false]) + closeEpochTx([pool.id, false], { + account, + forceProxyType: ['Borrow', 'Invest'], + }) } const ordersLocked = !epochTimeRemaining && sumOfLockedInvestments.add(sumOfLockedRedemptions).gt(0) - const ordersPartiallyExecutable = - (sumOfExecutableInvestments.gt(0) && sumOfExecutableInvestments.lt(sumOfLockedInvestments)) || - (sumOfExecutableRedemptions.gt(0) && sumOfExecutableRedemptions.lt(sumOfLockedRedemptions)) - const ordersFullyExecutable = - sumOfLockedInvestments.equals(sumOfExecutableInvestments) && - sumOfLockedRedemptions.equals(sumOfExecutableRedemptions) - const noOrdersExecutable = - !ordersFullyExecutable && sumOfExecutableInvestments.eq(0) && sumOfExecutableRedemptions.eq(0) + // const ordersPartiallyExecutable = + // (sumOfExecutableInvestments.gt(0) && sumOfExecutableInvestments.lt(sumOfLockedInvestments)) || + // (sumOfExecutableRedemptions.gt(0) && sumOfExecutableRedemptions.lt(sumOfLockedRedemptions)) + // const ordersFullyExecutable = + // sumOfLockedInvestments.equals(sumOfExecutableInvestments) && + // sumOfLockedRedemptions.equals(sumOfExecutableRedemptions) + // const noOrdersExecutable = + // !ordersFullyExecutable && sumOfExecutableInvestments.eq(0) && sumOfExecutableRedemptions.eq(0) return ( cent.pools.submitSolution, @@ -164,7 +174,7 @@ function EpochStatusSubmission({ pool }: { pool: Pool }) { // const submitSolution = async () => { // if (!pool) return - // submitSolutionTx([pool.id]) + // submitSolutionTx([pool.id], { account, forceProxyType: ['Borrow', 'Invest'] }) // } return ( @@ -200,6 +210,7 @@ function EpochStatusSubmission({ pool }: { pool: Pool }) { function EpochStatusExecution({ pool }: { pool: Pool }) { const { minutesRemaining, minutesTotal } = useChallengeTimeCountdown(pool.id) + const [account] = useSuitableAccounts({ poolId: pool.id, proxyType: ['Borrow', 'Invest'] }) const { execute: executeEpochTx, isLoading: loadingExecution } = useCentrifugeTransaction( 'Execute order', (cent) => cent.pools.executeEpoch @@ -207,7 +218,7 @@ function EpochStatusExecution({ pool }: { pool: Pool }) { const executeEpoch = () => { if (!pool) return - executeEpochTx([pool.id]) + executeEpochTx([pool.id], { account, forceProxyType: ['Borrow', 'Invest'] }) } return ( diff --git a/centrifuge-app/src/components/MaxReserveForm.tsx b/centrifuge-app/src/components/MaxReserveForm.tsx index d18e90e596..4d74afb22c 100644 --- a/centrifuge-app/src/components/MaxReserveForm.tsx +++ b/centrifuge-app/src/components/MaxReserveForm.tsx @@ -2,18 +2,15 @@ import { CurrencyBalance } from '@centrifuge/centrifuge-js' import { useCentrifugeTransaction } from '@centrifuge/centrifuge-react' import { Button, Card, CurrencyInput, Shelf, Stack, Text } from '@centrifuge/fabric' import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik' -import * as React from 'react' -import { useAddress } from '../utils/useAddress' -import { useLiquidityAdmin } from '../utils/usePermissions' +import { useSuitableAccounts } from '../utils/usePermissions' import { usePool } from '../utils/usePools' type Props = { poolId: string } -export const MaxReserveForm: React.FC = ({ poolId }) => { - const address = useAddress('substrate') - const isLiquidityAdmin = useLiquidityAdmin(poolId) +export function MaxReserveForm({ poolId }: Props) { + const [account] = useSuitableAccounts({ poolId, poolRole: ['LiquidityAdmin'] }) const pool = usePool(poolId) const { execute: setMaxReserveTx, isLoading } = useCentrifugeTransaction( @@ -29,7 +26,7 @@ export const MaxReserveForm: React.FC = ({ poolId }) => { enableReinitialize: true, onSubmit: (values, actions) => { if (typeof values.maxReserve === 'number' && values.maxReserve >= 0) { - setMaxReserveTx([poolId, CurrencyBalance.fromFloat(values.maxReserve, pool.currency.decimals)]) + setMaxReserveTx([poolId, CurrencyBalance.fromFloat(values.maxReserve, pool.currency.decimals)], { account }) } else { actions.setErrors({ maxReserve: 'Invalid number' }) } @@ -37,7 +34,7 @@ export const MaxReserveForm: React.FC = ({ poolId }) => { }, }) - if (!address || !isLiquidityAdmin) return null + if (!account) return null return ( diff --git a/centrifuge-app/src/components/Menu/PoolLink.tsx b/centrifuge-app/src/components/Menu/PoolLink.tsx index 09798d3a0f..e5a47decb9 100644 --- a/centrifuge-app/src/components/Menu/PoolLink.tsx +++ b/centrifuge-app/src/components/Menu/PoolLink.tsx @@ -1,6 +1,5 @@ import type { Pool } from '@centrifuge/centrifuge-js' import { Text } from '@centrifuge/fabric' -import * as React from 'react' import { useRouteMatch } from 'react-router' import { Link } from 'react-router-dom' import styled from 'styled-components' diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx index ad6501d5b3..910e4abf2f 100644 --- a/centrifuge-app/src/components/Menu/index.tsx +++ b/centrifuge-app/src/components/Menu/index.tsx @@ -1,10 +1,8 @@ import { Box, IconInvestments, IconNft, Menu as Panel, MenuItemGroup, Shelf, Stack } from '@centrifuge/fabric' -import * as React from 'react' import { config } from '../../config' import { useAddress } from '../../utils/useAddress' import { useIsAboveBreakpoint } from '../../utils/useIsAboveBreakpoint' -import { usePermissions } from '../../utils/usePermissions' -import { usePools } from '../../utils/usePools' +import { usePoolsThatAnyConnectedAddressHasPermissionsFor } from '../../utils/usePermissions' import { RouterLinkButton } from '../RouterLinkButton' import { GovernanceMenu } from './GovernanceMenu' import { IssuerMenu } from './IssuerMenu' @@ -12,21 +10,9 @@ import { PageLink } from './PageLink' import { PoolLink } from './PoolLink' export function Menu() { - const allPools = usePools(false) - const address = useAddress('substrate') - const permissions = usePermissions(address) + const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const isXLarge = useIsAboveBreakpoint('XL') - - const pools = React.useMemo(() => { - if (!allPools || !permissions) { - return [] - } - - return allPools.filter( - ({ id }) => - permissions.pools[id]?.roles.includes('PoolAdmin') || permissions.pools[id]?.roles.includes('MemberListAdmin') - ) - }, [allPools, permissions]) + const address = useAddress('substrate') return ( = ({ {subtitle && ( - - {subtitle} - + + + {subtitle} + + )} {headerRight} diff --git a/centrifuge-app/src/components/PendingMultisigs/index.tsx b/centrifuge-app/src/components/PendingMultisigs/index.tsx new file mode 100644 index 0000000000..00aae7839a --- /dev/null +++ b/centrifuge-app/src/components/PendingMultisigs/index.tsx @@ -0,0 +1,197 @@ +import { ComputedMultisig, computeMultisig, Multisig, PendingMultisigData } from '@centrifuge/centrifuge-js' +import { useCentrifugeApi, useCentrifugeQuery, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { Box, Button, Card, Dialog, Divider, Stack, Text, TextAreaInput } from '@centrifuge/fabric' +import * as React from 'react' +import { useAddress } from '../../utils/useAddress' +import { useSuitableAccounts } from '../../utils/usePermissions' +import { usePool, usePoolMetadata } from '../../utils/usePools' + +export function PendingMultisigs({ poolId }: { poolId: string }) { + const [multisigDialogOpen, setMultisigDialogOpen] = React.useState(false) + const pool = usePool(poolId) + const { data: metadata } = usePoolMetadata(pool) + + const multisig = metadata?.adminMultisig && computeMultisig(metadata.adminMultisig) + const multiAddress = multisig?.address + const [account] = useSuitableAccounts({ actingAddress: [multiAddress || ''] }) + + const [pendingMultisigs] = useCentrifugeQuery( + ['pendingMultisig', multiAddress], + (cent) => cent.multisig.getPendingTransactions([multiAddress!]), + { + enabled: !!multiAddress, + } + ) + + return ( + <> + {account && multisig && pendingMultisigs && pendingMultisigs?.length > 0 && ( + <> + setMultisigDialogOpen(false)} multisig={multisig} /> + + + {pendingMultisigs.length} pending multisig approval{pendingMultisigs.length > 0 && 's'} + + + + + )} + + ) +} + +function MultisigDialog({ + open, + onClose, + multisig, +}: { + open: boolean + onClose: () => void + multisig: ComputedMultisig +}) { + const [pendingMultisigs] = useCentrifugeQuery(['pendingMultisig', multisig.address], (cent) => + cent.multisig.getPendingTransactions([multisig.address]) + ) + return ( + + + <> + {pendingMultisigs?.map((data, i) => ( + <> + {i > 0 && } + + + ))} + + + + ) +} + +export function PendingMultisig({ + data, + multisig, + possibleCallData, +}: { + data: PendingMultisigData + multisig: Multisig + possibleCallData?: string +}) { + const { + approveOrReject, + isReject, + callFormInput, + callString, + transactionIsPending, + setCallFormInput, + callInputError, + isCallDataNeeded, + } = usePendingMultisigActions({ + data, + multisig, + possibleCallData, + }) + + return ( + + + Call hash: {data.hash} + + {isReject ? ( + + ) : ( + <> + {isCallDataNeeded && ( + <> + setCallFormInput(e.target.value)} + /> + {callInputError && ( + + Calldata doesn't match hash + + )} + + )} + {callString && ( +
+ Call details + +
{callString}
+
+
+ )} + + + )} +
+ ) +} + +export function usePendingMultisigActions({ + data, + multisig, + possibleCallData, +}: { + data: PendingMultisigData + multisig: Multisig + possibleCallData?: string +}) { + const address = useAddress('substrate') + const { execute: doTransaction, isLoading: transactionIsPending } = useCentrifugeTransaction( + 'Approve or cancel', + (cent) => cent.multisig.approveOrCancel + ) + const api = useCentrifugeApi() + const [callFormInput, setCallFormInput] = React.useState('') + + const callDataInput = callFormInput || possibleCallData + + const [callFromInput, inputValid] = React.useMemo(() => { + if (!callDataInput) return [null, false] + try { + const call = api.createType('Call', callDataInput) + + return [call, call.hash.eq(data.hash)] + } catch { + return [null, false] + } + }, [api, callDataInput, data.hash]) + + const call = data.call || (inputValid && callFromInput) + + const callString = React.useMemo(() => { + if (!call) return '' + try { + return JSON.stringify(call.toHuman(), null, 2) + } catch { + return '' + } + }, [call]) + + const isReject = data.info.approvals.includes(address!) + const isCallDataNeeded = !isReject && !data.callData && !possibleCallData + + return { + approveOrReject: () => + isReject + ? doTransaction([data.hash, multisig, undefined, true]) + : doTransaction([data.hash, multisig, isCallDataNeeded ? callFormInput : undefined]), + isReject, + callString, + transactionIsPending, + callFormInput, + setCallFormInput, + callInputError: !!callFormInput && !inputValid, + isCallDataNeeded, + } +} diff --git a/centrifuge-app/src/components/PodAuthProvider.tsx b/centrifuge-app/src/components/PodAuthProvider.tsx deleted file mode 100644 index afe463d0f8..0000000000 --- a/centrifuge-app/src/components/PodAuthProvider.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useCentrifuge, useWallet } from '@centrifuge/centrifuge-react' -import * as React from 'react' -import { useMutation, useQuery } from 'react-query' - -const AUTHORIZED_POD_PROXY_TYPES = ['Any', 'PodAuth', 'PodAdmin'] - -export const PodAuthContext = React.createContext<{ - session?: { signed: string; payload: any } | null - login: () => void - isLoggingIn: boolean -}>(null as any) - -export function PodAuthProvider({ children }: { children?: React.ReactNode }) { - const { selectedWallet, proxy, selectedAccount } = useWallet().substrate - const cent = useCentrifuge() - - const { data: session, refetch: refetchSession } = useQuery( - ['session', selectedAccount?.address, proxy?.delegator], - async () => { - if (selectedAccount?.address) { - if (proxy) { - const rawItem = sessionStorage.getItem(`centrifuge-auth-${selectedAccount.address}-${proxy.delegator}`) - if (rawItem) { - return JSON.parse(rawItem) - } - } else { - const rawItem = sessionStorage.getItem(`centrifuge-auth-${selectedAccount.address}`) - if (rawItem) { - return JSON.parse(rawItem) - } - } - } - }, - { enabled: !!selectedAccount?.address } - ) - - const { mutate: login, isLoading: isLoggingIn } = useMutation(async () => { - try { - if (selectedAccount?.address && selectedWallet?.signer) { - const { address } = selectedAccount - - if (proxy) { - const proxyType = proxy?.types.includes('Any') - ? 'Any' - : proxy?.types.includes('PodAuth') - ? 'PodAuth' - : 'PodAdmin' - - // @ts-expect-error Signer type version mismatch - const { token, payload } = await cent.auth.generateJw3t(address, selectedWallet?.signer, { - onBehalfOf: proxy.delegator, - proxyType, - }) - - if (token) { - const isAuthorizedProxy = await cent.auth.verifyProxy(address, proxy.delegator, AUTHORIZED_POD_PROXY_TYPES) - - if (isAuthorizedProxy) { - sessionStorage.setItem( - `centrifuge-auth-${selectedAccount.address}-${proxy.delegator}`, - JSON.stringify({ signed: token, payload }) - ) - refetchSession() - } - } - } else { - // @ts-expect-error Signer type version mismatch - const { token, payload } = await cent.auth.generateJw3t(address, selectedWallet?.signer) - - if (token) { - sessionStorage.setItem( - `centrifuge-auth-${selectedAccount.address}`, - JSON.stringify({ signed: token, payload }) - ) - refetchSession() - } - } - } - } catch {} - }) - - const ctx = React.useMemo( - () => ({ - session, - login, - isLoggingIn, - }), - [session, login, isLoggingIn] - ) - - return {children} -} - -export function useAuth() { - const ctx = React.useContext(PodAuthContext) - if (!ctx) throw new Error('useAuth must be used within AuthProvider') - const { selectedAccount } = useWallet().substrate - - const cent = useCentrifuge() - - const { session } = ctx - - const authToken = session?.signed ? session.signed : '' - - const { refetch: refetchAuth, data } = useQuery( - ['authToken', authToken], - async () => { - try { - const { verified, payload } = await cent.auth.verify(authToken!) - - const onBehalfOf = payload.on_behalf_of - const address = payload.address - - if (verified) { - if (payload.on_behalf_of) { - const isVerifiedProxy = await cent.auth.verifyProxy(address, onBehalfOf, AUTHORIZED_POD_PROXY_TYPES) - - if (isVerifiedProxy.verified) { - return { - verified: true, - payload, - } - } - } else { - return { - verified: true, - payload, - } - } - } - - return { - verified: false, - } - } catch { - return { - verified: false, - } - } - }, - { - enabled: !!selectedAccount && !!authToken, - staleTime: Infinity, - retry: 1, - } - ) - - return { - authToken, - isAuth: data?.verified, - login: ctx.login, - refetchAuth, - } -} diff --git a/centrifuge-app/src/components/PodAuthSection.tsx b/centrifuge-app/src/components/PodAuthSection.tsx index 56849c54fb..552b6a1f74 100644 --- a/centrifuge-app/src/components/PodAuthSection.tsx +++ b/centrifuge-app/src/components/PodAuthSection.tsx @@ -1,36 +1,36 @@ import { useWallet } from '@centrifuge/centrifuge-react' import { Button, IconAlertCircle, Shelf, Stack, Text } from '@centrifuge/fabric' import * as React from 'react' -import { usePod } from '../utils/usePod' +import { usePodAuth } from '../utils/usePodAuth' type Props = { - podUrl: string message?: string buttonLabel?: string + poolId: string } export const PodAuthSection: React.FC = ({ - podUrl, message = 'This information is private', buttonLabel = 'Authenticate', + poolId, }) => { const { selectedAccount } = useWallet().substrate - const { isLoggedIn, isPodLoading, loginError, login } = usePod(podUrl) + const { isAuthing, isAuthed, authError, login } = usePodAuth(poolId) - return isLoggedIn ? null : ( + return isAuthed ? null : ( {message} {selectedAccount?.address && ( - )} <> - {loginError && ( + {authError && ( Failed to authenticate diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx index 6b65b5582f..2b255c2182 100644 --- a/centrifuge-app/src/components/Root.tsx +++ b/centrifuge-app/src/components/Root.tsx @@ -23,6 +23,7 @@ import { IssuerPoolPage } from '../pages/IssuerPool' import { IssuerCreateLoanPage } from '../pages/IssuerPool/Assets/CreateLoan' import { LoanPage } from '../pages/Loan' import { MintNFTPage } from '../pages/MintNFT' +import { MultisigApprovalPage } from '../pages/MultisigApproval' import { NFTPage } from '../pages/NFT' import { NotFoundPage } from '../pages/NotFound' import { OnboardingPage } from '../pages/Onboarding' @@ -39,7 +40,6 @@ import { Head } from './Head' import { LoadBoundary } from './LoadBoundary' import { OnboardingAuthProvider } from './OnboardingAuthProvider' import { OnboardingProvider } from './OnboardingProvider' -import { PodAuthProvider } from './PodAuthProvider' const queryClient = new QueryClient({ defaultOptions: { @@ -94,8 +94,9 @@ const evmChains: EvmChains = }, } -export const Root: React.VFC = () => { +export function Root() { const [isThemeToggled, setIsThemeToggled] = React.useState(!!initialFlagsState.alternativeTheme) + const [showAdvancedAccounts, setShowAdvancedAccounts] = React.useState(!!initialFlagsState.showAdvancedAccounts) return ( <> @@ -116,23 +117,30 @@ export const Root: React.VFC = () => { - - - - - setIsThemeToggled(!!state.alternativeTheme)}> - - - - - - - - - - - - + + + + { + setIsThemeToggled(!!state.alternativeTheme) + setShowAdvancedAccounts(!!state.showAdvancedAccounts) + }} + > + + + + + + + + + + + @@ -141,7 +149,7 @@ export const Root: React.VFC = () => { ) } -const Routes: React.VFC = () => { +function Routes() { return ( @@ -195,6 +203,9 @@ const Routes: React.VFC = () => { + + + diff --git a/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx b/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx new file mode 100644 index 0000000000..35a6cc0537 --- /dev/null +++ b/centrifuge-app/src/pages/IssuerCreatePool/AdminMultisig.tsx @@ -0,0 +1,43 @@ +import { useWallet } from '@centrifuge/centrifuge-react' +import { Button } from '@centrifuge/fabric' +import { useFormikContext } from 'formik' +import { CreatePoolValues } from '.' +import { PageSection } from '../../components/PageSection' +import { MultisigForm } from '../IssuerPool/Access/MultisigForm' + +export function AdminMultisigSection() { + const form = useFormikContext() + const { + substrate: { selectedAddress }, + } = useWallet() + const { adminMultisigEnabled } = form.values + + return ( + form.setFieldValue('adminMultisigEnabled', false)}> + Disable + + ) : ( + + ) + } + > + {adminMultisigEnabled && } + + ) +} diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index d6ca90407c..e1e1543853 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -1,6 +1,12 @@ -import { CurrencyBalance, Perquintill, Rate } from '@centrifuge/centrifuge-js' -import { PoolMetadataInput } from '@centrifuge/centrifuge-js/dist/modules/pools' -import { useBalances, useCentrifuge, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { CurrencyBalance, isSameAddress, Perquintill, Rate } from '@centrifuge/centrifuge-js' +import { CurrencyKey, PoolMetadataInput, TrancheInput } from '@centrifuge/centrifuge-js/dist/modules/pools' +import { + useBalances, + useCentrifuge, + useCentrifugeConsts, + useCentrifugeTransaction, + useWallet, +} from '@centrifuge/centrifuge-react' import { Box, Button, @@ -13,25 +19,30 @@ import { TextWithPlaceholder, Thumbnail, } from '@centrifuge/fabric' +import { createKeyMulti, sortAddresses } from '@polkadot/util-crypto' +import BN from 'bn.js' import { Field, FieldProps, Form, FormikErrors, FormikProvider, setIn, useFormik } from 'formik' import * as React from 'react' import { useHistory } from 'react-router' -import { lastValueFrom, tap } from 'rxjs' +import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs' import { PreimageHashDialog } from '../../components/Dialogs/PreimageHashDialog' +import { ShareMultisigDialog } from '../../components/Dialogs/ShareMultisigDialog' import { FieldWithErrorMessage } from '../../components/FieldWithErrorMessage' import { PageHeader } from '../../components/PageHeader' import { PageSection } from '../../components/PageSection' import { PageWithSideBar } from '../../components/PageWithSideBar' import { Tooltips } from '../../components/Tooltips' import { config } from '../../config' +import { Dec } from '../../utils/Decimal' import { formatBalance } from '../../utils/formatting' import { getFileDataURI } from '../../utils/getFileDataURI' import { useAddress } from '../../utils/useAddress' +import { useCreatePoolFee } from '../../utils/useCreatePoolFee' import { usePoolCurrencies } from '../../utils/useCurrencies' import { useFocusInvalidInput } from '../../utils/useFocusInvalidInput' import { usePools } from '../../utils/usePools' -import { useProposalEstimate } from '../../utils/useProposalEstimate' import { truncate } from '../../utils/web3' +import { AdminMultisigSection } from './AdminMultisig' import { IssuerInput } from './IssuerInput' import { TrancheSection } from './TrancheInput' import { useStoredIssuer } from './useStoredIssuer' @@ -72,10 +83,15 @@ export const createEmptyTranche = (junior?: boolean): Tranche => ({ minInvestment: 0, }) -export type CreatePoolValues = Omit & { +export type CreatePoolValues = Omit< + PoolMetadataInput, + 'poolIcon' | 'issuerLogo' | 'executiveSummary' | 'adminMultisig' +> & { poolIcon: File | null issuerLogo: File | null executiveSummary: File | null + adminMultisigEnabled: boolean + adminMultisig: Exclude } const initialValues: CreatePoolValues = { @@ -101,6 +117,11 @@ const initialValues: CreatePoolValues = { details: [], tranches: [createEmptyTranche(true)], + adminMultisig: { + signers: [], + threshold: 1, + }, + adminMultisigEnabled: false, } const PoolIcon: React.FC<{ icon?: File | null; children: string }> = ({ children, icon }) => { @@ -115,18 +136,24 @@ const PoolIcon: React.FC<{ icon?: File | null; children: string }> = ({ children return uri ? : } -const CreatePoolForm: React.VFC = () => { +function CreatePoolForm() { const address = useAddress('substrate') + const { + substrate: { addMultisig }, + } = useWallet() const centrifuge = useCentrifuge() const currencies = usePoolCurrencies() + const { chainDecimals } = useCentrifugeConsts() const pools = usePools() const history = useHistory() const balances = useBalances(address) const { data: storedIssuer, isLoading: isStoredIssuerLoading } = useStoredIssuer() const [waitingForStoredIssuer, setWaitingForStoredIssuer] = React.useState(true) - const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [isPreimageDialogOpen, setIsPreimageDialogOpen] = React.useState(false) + const [isMultisigDialogOpen, setIsMultisigDialogOpen] = React.useState(false) const [preimageHash, setPreimageHash] = React.useState('') const [createdPoolId, setCreatedPoolId] = React.useState('') + const [multisigData, setMultisigData] = React.useState<{ hash: string; callData: string }>() React.useEffect(() => { // If the hash can't be found on Pinata the request can take a long time to time out @@ -155,11 +182,74 @@ const CreatePoolForm: React.VFC = () => { notePreimage: 'Note preimage', } const { execute: createPoolTx, isLoading: transactionIsPending } = useCentrifugeTransaction( - txMessage[config.poolCreationType || 'immediate'], - (cent) => cent.pools.createPool, + `${txMessage[config.poolCreationType || 'immediate']} 2/2`, + (cent) => + ( + args: [ + transferToMultisig: BN, + aoProxy: string, + admin: string, + poolId: string, + collectionId: string, + tranches: TrancheInput[], + currency: CurrencyKey, + maxReserve: BN, + metadata: PoolMetadataInput + ], + options + ) => { + const [transferToMultisig, aoProxy, admin, , , , , , { adminMultisig }] = args + const multisigAddr = adminMultisig && createKeyMulti(adminMultisig.signers, adminMultisig.threshold) + const poolArgs = args.slice(2) as any + return combineLatest([cent.getApi(), cent.pools.createPool(poolArgs, { batch: true })]).pipe( + switchMap(([api, poolSubmittable]) => { + const manager = multisigAddr ?? address + const otherMultisigSigners = + multisigAddr && sortAddresses(adminMultisig.signers.filter((addr) => !isSameAddress(addr, address!))) + const proxiedPoolCreate = api.tx.proxy.proxy(admin, undefined, poolSubmittable) + const submittable = api.tx.utility.batchAll( + [ + api.tx.balances.transfer( + admin, + new CurrencyBalance(api.consts.proxy.proxyDepositFactor, chainDecimals).add(transferToMultisig) + ), + api.tx.balances.transfer( + aoProxy, + new CurrencyBalance(api.consts.proxy.proxyDepositFactor, chainDecimals).add( + new CurrencyBalance(api.consts.uniques.collectionDeposit, chainDecimals) + ) + ), + manager !== address && + api.tx.proxy.proxy( + admin, + undefined, + api.tx.utility.batchAll([ + api.tx.proxy.addProxy(manager, 'Any', 0), + api.tx.proxy.removeProxy(address, 'Any', 0), + ]) + ), + api.tx.proxy.proxy( + aoProxy, + undefined, + api.tx.utility.batchAll([ + api.tx.proxy.addProxy(admin, 'Any', 0), + api.tx.proxy.removeProxy(address, 'Any', 0), + ]) + ), + multisigAddr + ? api.tx.multisig.asMulti(adminMultisig.threshold, otherMultisigSigners, null, proxiedPoolCreate, 0) + : proxiedPoolCreate, + ].filter(Boolean) + ) + setMultisigData({ callData: proxiedPoolCreate.method.toHex(), hash: proxiedPoolCreate.method.hash.toHex() }) + return cent.wrapSignAndSend(api, submittable, { ...options, multisig: undefined, proxy: [] }) + }) + ) + }, { onSuccess: (args) => { - const [, poolId] = args + if (form.values.adminMultisigEnabled) setIsMultisigDialogOpen(true) + const [, , , poolId] = args if (config.poolCreationType === 'immediate') { setCreatedPoolId(poolId) } @@ -167,6 +257,33 @@ const CreatePoolForm: React.VFC = () => { } ) + const { execute: createProxies, isLoading: createProxiesIsPending } = useCentrifugeTransaction( + `${txMessage[config.poolCreationType || 'immediate']} 1/2`, + (cent) => { + return (_: [nextTx: (adminProxy: string, aoProxy: string) => void], options) => + cent.getApi().pipe( + switchMap((api) => { + const submittable = api.tx.utility.batchAll([ + api.tx.proxy.createPure('Any', 0, 0), + api.tx.proxy.createPure('Any', 0, 1), + ]) + return cent.wrapSignAndSend(api, submittable, options) + }) + ) + }, + { + onSuccess: async ([nextTx], result) => { + const api = await centrifuge.getApiPromise() + const events = result.events.filter(({ event }) => api.events.proxy.PureCreated.is(event)) + if (!events) return + const { pure } = (events[0].toHuman() as any).event.data + const { pure: pure2 } = (events[1].toHuman() as any).event.data + + nextTx(pure, pure2) + }, + } + ) + const form = useFormik({ initialValues, validate: (values) => { @@ -177,6 +294,7 @@ const CreatePoolForm: React.VFC = () => { const tokenSymbols = new Set() let prevInterest = Infinity let prevRiskBuffer = 0 + values.tranches.forEach((t, i) => { if (tokenNames.has(t.tokenName)) { errors = setIn(errors, `tranches.${i}.tokenName`, 'Tranche names must be unique') @@ -216,9 +334,16 @@ const CreatePoolForm: React.VFC = () => { }, validateOnMount: true, onSubmit: async (values, { setSubmitting }) => { - if (!currencies) return + if (!currencies || !address) return + const metadataValues: PoolMetadataInput = { ...values } as any - if (!address) return + + metadataValues.adminMultisig = values.adminMultisigEnabled + ? { + ...values.adminMultisig, + signers: sortAddresses(values.adminMultisig.signers), + } + : undefined const currency = currencies.find((c) => c.symbol === values.currency)! @@ -251,18 +376,28 @@ const CreatePoolForm: React.VFC = () => { // const epochSeconds = ((values.epochHours as number) * 60 + (values.epochMinutes as number)) * 60 - createPoolTx( - [ - address, - poolId, - collectionId, - tranches, - currency.key, - CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), - metadataValues, - ], - { createType: config.poolCreationType } - ) + if (metadataValues.adminMultisig) { + addMultisig(metadataValues.adminMultisig) + } + + createProxies([ + (aoProxy, adminProxy) => { + createPoolTx( + [ + CurrencyBalance.fromFloat(createDeposit, chainDecimals), + aoProxy, + adminProxy, + poolId, + collectionId, + tranches, + currency.key, + CurrencyBalance.fromFloat(values.maxReserve, currency.decimals), + metadataValues, + ], + { createType: config.poolCreationType } + ) + }, + ]) setSubmitting(false) }, @@ -294,7 +429,7 @@ const CreatePoolForm: React.VFC = () => { if (!parsedEvent) return false console.info('Preimage hash: ', parsedEvent.event.data[0]) setPreimageHash(parsedEvent.event.data[0]) - setIsDialogOpen(true) + setIsPreimageDialogOpen(true) }) ) .subscribe() @@ -305,11 +440,28 @@ const CreatePoolForm: React.VFC = () => { const formRef = React.useRef(null) useFocusInvalidInput(form, formRef) - const { proposeFee } = useProposalEstimate(form?.values) + const { proposeFee, poolDeposit, proxyDeposit, collectionDeposit } = useCreatePoolFee(form?.values) + const createDeposit = (proposeFee?.toDecimal() ?? Dec(0)) + .add(poolDeposit.toDecimal()) + .add(collectionDeposit.toDecimal()) + const deposit = createDeposit.add(proxyDeposit.toDecimal()) return ( <> - setIsDialogOpen(false)} /> + setIsPreimageDialogOpen(false)} + /> + {multisigData && ( + setIsMultisigDialogOpen(false)} + /> + )}
{ } actions={ <> - {proposeFee && ( - - Deposit required: ~{formatBalance(proposeFee, balances?.native.currency.symbol, 1)} - - )} + + Deposit required: {formatBalance(deposit, balances?.native.currency.symbol, 1)} + + - @@ -429,6 +584,8 @@ const CreatePoolForm: React.VFC = () => { + +
diff --git a/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx b/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx new file mode 100644 index 0000000000..57d26d7a8e --- /dev/null +++ b/centrifuge-app/src/pages/IssuerPool/Access/AssetOriginators.tsx @@ -0,0 +1,413 @@ +import { addressToHex, computeTrancheId, isSameAddress, TransactionOptions } from '@centrifuge/centrifuge-js' +import { useCentrifuge, useCentrifugeConsts, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { Button, IconMinusCircle, Stack, Text, TextInput } from '@centrifuge/fabric' +import { sortAddresses } from '@polkadot/util-crypto' +import { BN } from 'bn.js' +import { FieldArray, Form, FormikProvider, useFormik } from 'formik' +import * as React from 'react' +import { combineLatest, switchMap } from 'rxjs' +import { ButtonGroup } from '../../../components/ButtonGroup' +import { DataTable } from '../../../components/DataTable' +import { FieldWithErrorMessage } from '../../../components/FieldWithErrorMessage' +import { Identity } from '../../../components/Identity' +import { PageSection } from '../../../components/PageSection' +import { useIdentity } from '../../../utils/useIdentity' +import { usePoolAccess, useSuitableAccounts } from '../../../utils/usePermissions' +import { required } from '../../../utils/validation' +import { AddAddressInput } from '../Configuration/AddAddressInput' +import { diffPermissions } from '../Configuration/Admins' + +type AOFormValues = { + withdrawAddress: string + name: string + delegates: string[] + p2pKey: string + documentKey: string + podOperator: string +} + +export function AssetOriginators({ poolId }: { poolId: string }) { + const access = usePoolAccess(poolId) + const { + proxy: { proxyDepositBase, proxyDepositFactor }, + } = useCentrifugeConsts() + + const [account] = useSuitableAccounts({ poolId, poolRole: ['PoolAdmin'], actingAddress: [access.admin || ''] }) + + const { execute: createAO, isLoading: createAOIsPending } = useCentrifugeTransaction( + 'Create asset originator', + (cent) => (_args: [], options?: TransactionOptions) => { + return combineLatest([ + cent.getApi(), + cent.proxies.createPure([], { batch: true }), + cent.pools.updatePoolRoles([poolId, access.missingAdminPermissions, []], { batch: true }), + ]).pipe( + switchMap(([api, createTx, permissionTx]) => { + const tx = api.tx.utility.batchAll([ + ...permissionTx.method.args[0], + api.tx.balances.transfer(account.actingAddress, proxyDepositBase.add(proxyDepositFactor)), + createTx, + ]) + return cent.wrapSignAndSend(api, tx, options) + }) + ) + } + ) + + return ( + createAO([], { account })} + small + loading={createAOIsPending} + disabled={!account} + > + Create new + + } + > + {access.assetOriginators.map((ao) => ( + + ))} + + ) +} + +type Row = { + address: string + index: number +} + +// TODO: Edit withdraw address for restricted transfers +function AOForm({ + access, + assetOriginator: ao, + poolId, +}: { + access: ReturnType + assetOriginator: ReturnType['assetOriginators'][0] + poolId: string +}) { + const [isEditing, setIsEditing] = React.useState(false) + const [account] = useSuitableAccounts({ poolId, actingAddress: [ao.address] }).filter((a) => a.proxies?.length === 2) + const identity = useIdentity(ao.address) + const cent = useCentrifuge() + const { + proxy: { proxyDepositFactor }, + uniques: { collectionDeposit }, + identity: { basicDeposit: nameDeposit }, + keystore: { keyDeposit }, + } = useCentrifugeConsts() + + const initialValues: AOFormValues = React.useMemo( + () => ({ + name: identity?.display || '', + withdrawAddress: '', + delegates: ao.delegates.map((d) => d.delegatee), + p2pKey: '', + documentKey: '', + podOperator: '', + }), + [ao, identity] + ) + + const storedAORoles = { + address: ao.address, + roles: Object.fromEntries(ao.permissions.roles.map((role) => [role, true])), + } + + const { execute, isLoading } = useCentrifugeTransaction( + 'Update asset originator', + (cent) => + ( + args: [ + name?: string, + withdrawAddress?: string, + addedPermissions?: ReturnType['add'], + addedAddresses?: string[], + removedAddresses?: string[], + keys?: { + p2pKey: string + documentKey: string + }, + podOperator?: string, + collectionId?: string + ], + options + ) => { + const [, , addedPermissions = [], addedAddresses = [], removedAddresses = [], keys, podOperator, collectionId] = + args + + return combineLatest([ + cent.getApi(), + cent.pools.updatePoolRoles([poolId, [...access.missingPermissions, ...addedPermissions], []], { + batch: true, + }), + ]).pipe( + switchMap(([api, permissionTx]) => { + const numProxyTypesPerHotWallet = 3 + const deposit = proxyDepositFactor + .mul(new BN((addedAddresses.length - removedAddresses.length) * numProxyTypesPerHotWallet)) + .add(podOperator ? proxyDepositFactor : new BN(0)) + .add(collectionId ? collectionDeposit : new BN(0)) + .add(keys ? keyDeposit.mul(new BN(2)) : new BN(0)) + // .add(name && !initialValues.name ? nameDeposit : new BN(0)) + + // doing the proxy and multisig transactions manually, because both the Pool Admin and the AO need to call extrinsics + let tx = api.tx.proxy.proxy( + account.proxies![0].delegator, + undefined, + api.tx.utility.batchAll([ + ...permissionTx.method.args[0], // Adding the permissions needs to be done by the Pool Admin, the rest by the AO + api.tx.proxy.proxy( + account.proxies![1].delegator, + undefined, + api.tx.utility.batchAll( + [ + removedAddresses.length && + api.tx.utility.batch( + removedAddresses + .map((addr) => [ + api.tx.proxy.removeProxy(addr, 'Borrow', 0), + api.tx.proxy.removeProxy(addr, 'Invest', 0), + api.tx.proxy.removeProxy(addr, 'PodAuth', 0), + ]) + .flat() + ), + addedAddresses.map((addr) => [ + api.tx.proxy.addProxy(addr, 'Borrow', 0), + api.tx.proxy.addProxy(addr, 'Invest', 0), + api.tx.proxy.addProxy(addr, 'PodAuth', 0), + // TODO: Restricted Transfer + ]), + podOperator && api.tx.proxy.addProxy(podOperator, 'PodOperation', 0), + keys && + api.tx.keystore.addKeys([ + [keys.p2pKey, 'P2PDiscovery', 'ECDSA'], + [keys.documentKey, 'P2PDocumentSigning', 'ECDSA'], + ]), + collectionId && [api.tx.uniques.create(collectionId, ao.address)], + ] + .filter(Boolean) + .flat(2) + ) + ), + ]) + ) + + if (options?.multisig) { + const otherSigners = sortAddresses( + options.multisig.signers.filter((signer) => !isSameAddress(signer, cent.getSignerAddress())) + ) + console.log('multisig callData', tx.method.toHex()) + tx = api.tx.multisig.asMulti(options.multisig.threshold, otherSigners, null, tx, 0) + } + + if (!deposit.isZero()) { + tx = api.tx.utility.batchAll([ + !deposit.isZero() && api.tx.balances.transfer(account.proxies![1].delegator, deposit), + tx, + ]) + } + + return cent.wrapSignAndSend(api, tx, { + ...options, + proxies: [], + multisig: undefined, + }) + }) + ) + }, + { + onSuccess: () => { + setIsEditing(false) + }, + } + ) + + const form = useFormik({ + initialValues, + onSubmit: async (values, actions) => { + actions.setSubmitting(false) + const addedDelegates = values.delegates.filter((addr) => !initialValues.delegates.includes(addr)) + const removedDelegates = initialValues.delegates.filter((addr) => !values.delegates.includes(addr)) + + const addedPermissions = diffPermissions( + [storedAORoles], + [{ address: ao.address, roles: { Borrower: true, LoanAdmin: true } }], + ['Borrower', 'LoanAdmin'] + ).add + const junTranche = computeTrancheId(0, poolId) + if (!ao.permissions.tranches[junTranche]) { + addedPermissions.push([ + ao.address, + { TrancheInvestor: [junTranche, Math.floor(Date.now() / 1000 + 10 * 365 * 24 * 60 * 60)] } as any, + ]) + } + + execute( + [ + ifChanged(values, initialValues, 'name'), + ifChanged(values, initialValues, 'withdrawAddress'), + addedPermissions, + addedDelegates, + removedDelegates, + values.p2pKey && values.documentKey ? { p2pKey: values.p2pKey, documentKey: values.documentKey } : undefined, + values.podOperator, + !ao.collateralCollections.length ? await cent.nfts.getAvailableCollectionId() : undefined, + ], + { account } + ) + }, + }) + + React.useEffect(() => { + if (isEditing && !isLoading) return + form.resetForm() + form.setValues(initialValues, false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues, isEditing]) + + const rows = React.useMemo( + () => form.values.delegates.map((a, i) => ({ address: a, index: i })), + [form.values.delegates] + ) + + const hasChanges = + (!!form.values.documentKey && !!form.values.p2pKey) || + form.values.name !== initialValues.name || + form.values.delegates.length !== initialValues.delegates.length || + !form.values.delegates.every((s) => initialValues.delegates.includes(s)) + + return ( + +
+ } + headerRight={ + isEditing ? ( + + + + + ) : ( + + ) + } + > + + {!ao.isSetUp && isEditing && ( + + + POD Setup + + + Values that need to be set in order to be able to authenticate with the POD and create assets + + + + + + )} + + + + Delegates + + + Add or remove addresses which can originate assets and invest in the junior tranche. + + + {(fldArr) => ( + + ( + + + + ), + flex: '3', + }, + { + header: '', + cell: (row: Row) => + isEditing && ( +