diff --git a/dapp/ARC200ActionsTab.tsx b/dapp/ARC200ActionsTab.tsx new file mode 100644 index 00000000..f1950be7 --- /dev/null +++ b/dapp/ARC200ActionsTab.tsx @@ -0,0 +1,371 @@ +import { + AlgorandProvider, + BaseError, + IBaseResult, + ISignTxnsResult, +} from '@agoralabs-sh/algorand-provider'; +import { + Button, + Code, + CreateToastFnReturn, + Grid, + GridItem, + HStack, + Input, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Radio, + RadioGroup, + Spacer, + Stack, + TabPanel, + Text, + VStack, +} from '@chakra-ui/react'; +import { decode as decodeBase64 } from '@stablelib/base64'; +import { encode as encodeHex } from '@stablelib/hex'; +import { decodeSignedTransaction, SignedTransaction } from 'algosdk'; +import BigNumber from 'bignumber.js'; +import React, { ChangeEvent, FC, useEffect, useState } from 'react'; + +// enums +import { TransactionTypeEnum } from '@extension/enums'; +// TODO add method enums for arc200 + +// theme +import { theme } from '@extension/theme'; + +// types +import { INetwork } from '@extension/types'; +import { IWindow } from '@external/types'; +import { IAccountInformation } from './types'; + +// utils +import { convertToStandardUnit } from '@common/utils'; +import { getNotPureStakeAlgodClient } from './utils'; +import getArc200AssetClient from './utils/getArc200Client'; + +interface IProps { + account: IAccountInformation | null; + network: INetwork | null; + toast: CreateToastFnReturn; +} + +const ARC200ActionsTab: FC = ({ account, network, toast }: IProps) => { + const [amount, setAmount] = useState(new BigNumber('0')); + const [signedTransaction, setSignedTransaction] = + useState(null); + //const [note, setNote] = useState(''); + const [assets, setAssets] = useState([ + '6779767', // VIA + '6778021', // VRC200 + ]); + const [assetInfo, setAssetInformation] = useState(null); + /* + id: '6779767', + decimals: 6, + balance: 0, + name: 'Voi Incentive Accets', + symbol: 'VIA', + }); + */ + const [selectedAsset, setSelectedAsset] = useState(null); + const [address, setAddress] = useState(''); + + const handleAmountChange = (valueAsString: string) => + setAmount(new BigNumber(valueAsString)); + + const handleAddressChange = (event: ChangeEvent) => + setAddress(event.target.value); + + const handleSelectAssetChange = (assetId: string) => { + if (!account) { + return; + } + handleUpdateAsset(assets.find((value) => value === assetId) || null); + }; + const handleSignTransactionClick = + (type: TransactionTypeEnum) => async () => { + const algorand: AlgorandProvider | undefined = (window as IWindow) + .algorand; + let result: IBaseResult & ISignTxnsResult; + let unsignedTransactions: string[] | null = null; + + if (!account || !network) { + toast({ + description: 'You must first enable the dApp with the wallet.', + status: 'error', + title: 'No Account Not Found!', + }); + + return; + } + + if (!algorand) { + toast({ + description: + 'Algorand Provider has been intialized; there is no supported wallet.', + status: 'error', + title: 'window.algorand Not Found!', + }); + + return; + } + + try { + switch (type) { + case TransactionTypeEnum.AssetTransfer: { + if (!selectedAsset) { + toast({ + description: 'Select an asset from the list.', + status: 'error', + title: 'No Asset Selected!', + }); + + return; + } + const { arc200_transfer, arc200_decimals } = + await getArc200AssetClient( + account, + network as INetwork, + Number(selectedAsset) + ); + const decimals = await arc200_decimals(); + // TODO: fix this + const aus = BigNumber(amount) + .multipliedBy(BigNumber(10).pow(decimals.returnValue)) + .toNumber(); + const res = await arc200_transfer(address, aus); + if (!res.success) { + toast({ + status: 'error', + title: 'Transaction Failed!', + description: `Failed to transfer asset "${selectedAsset}"`, + }); + return; + } + unsignedTransactions = res.txns; + break; + } + default: + break; + } + + if (!unsignedTransactions) { + toast({ + status: 'error', + title: 'Unknown Transaction Type', + }); + return; + } + + result = await algorand.signTxns({ + txns: unsignedTransactions.map((txn) => ({ txn })), + }); + + const r = await getNotPureStakeAlgodClient(network as INetwork) + .sendRawTransaction( + result.stxns.map( + (stxn: string) => new Uint8Array(Buffer.from(stxn, 'base64')) + ) + ) + .do(); + // TODO: confirm transaction + + toast({ + description: `Successfully signed transaction for wallet "${result.id}".`, + status: 'success', + title: 'Transaction Signed!', + }); + + if (result.stxns[0]) { + setSignedTransaction( + decodeSignedTransaction(decodeBase64(result.stxns[0])) + ); + } + } catch (error) { + toast({ + description: (error as BaseError).message, + status: 'error', + title: `${(error as BaseError).code}: ${(error as BaseError).name}`, + }); + } + }; + const handleUpdateAsset = async (newSelectedAsset: string | null) => { + const { getMetadata, arc200_balanceOf } = await getArc200AssetClient( + account, + network as INetwork, + Number(newSelectedAsset) + ); + const tm = await getMetadata(); + const bal = await arc200_balanceOf(account.address); + if (!tm.success || !bal.success) { + // TODO: handle error + // TODO: show error message + return; + } + setAssetInformation({ + id: newSelectedAsset, + balance: bal.returnValue, + ...tm.returnValue, + }); + setSelectedAsset(newSelectedAsset); + }; + + useEffect(() => { + if (account && !selectedAsset) { + setSelectedAsset(account.assets[0] || null); + } + }, [account]); + + return ( + + + {/*Amount*/} + + + Amount: + + + + + + + + + + + {/*Address*/} + + + + Address: + + + + + {/*Assets*/} + + Assets: + + {assets.length > 0 ? ( + <> + + + {assets.map((asset, index) => ( + + + {asset} + + ))} + + + {selectedAsset && assetInfo && ( + + {assetInfo.id} + + {assetInfo && ( + + {convertToStandardUnit( + assetInfo.balance, + assetInfo.decimals + ).toString()} + + )} + {assetInfo.symbol && {assetInfo.symbol}} + + )} + + ) : ( + + + No assets found! + + + )} + + {/*Signed transaction data*/} + + + Signed transaction: + + {signedTransaction?.txn.toString() || '-'} + + + + Signed transaction signature (hex): + + {signedTransaction?.sig + ? encodeHex(signedTransaction.sig).toUpperCase() + : '-'} + + + + + {/*sign transaction button*/} + + {[ + { + disabled: !selectedAsset, + type: TransactionTypeEnum.AssetTransfer, + label: 'Transfer', + }, + ].map(({ disabled, label, type }, index) => ( + + + + ))} + + + + ); +}; + +export default ARC200ActionsTab; diff --git a/dapp/App.tsx b/dapp/App.tsx index abaa1db9..2de5f2db 100644 --- a/dapp/App.tsx +++ b/dapp/App.tsx @@ -42,6 +42,7 @@ import { networks } from '@extension/config'; // tabs import ApplicationActionsTab from './ApplicationActionsTab'; import AssetActionsTab from './AssetActionsTab'; +import ARC200ActionsTab from './ARC200ActionsTab'; import AtomicTransactionActionsTab from './AtomicTransactionActionsTab'; import KeyRegistrationActionsTab from './KeyRegistrationActionsTab'; import PaymentActionsTab from './PaymentActionsTab'; @@ -276,6 +277,7 @@ const App: FC = () => { Payments Assets + Assets (ARC200) Atomic Txns Apps Keys @@ -293,6 +295,11 @@ const App: FC = () => { network={selectedNetwork} toast={toast} /> + { + // TODO: type this contract instance + const client: Algodv2 = getNotPureStakeAlgodClient(network); + const ci = new Contract(tokenId, client, { + addr: account.address, + simulate: true, + formatBytes: true, + waitForConfirmation: false, + }); + return ci; +} diff --git a/package.json b/package.json index aaa0ff31..92329d5a 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,8 @@ "@walletconnect/core": "^2.8.0", "@walletconnect/utils": "^2.8.0", "@walletconnect/web3wallet": "^1.8.0", - "algosdk": "^2.1.0", + "algosdk": "2.7.0", + "arc200js": "^1.2.22", "bignumber.js": "^9.1.1", "buffer": "^6.0.3", "chakra-ui-steps": "2.0.4", diff --git a/src/extension/components/NativeBalance/NativeBalance.tsx b/src/extension/components/NativeBalance/NativeBalance.tsx index 13571c18..7f8c7e2d 100644 --- a/src/extension/components/NativeBalance/NativeBalance.tsx +++ b/src/extension/components/NativeBalance/NativeBalance.tsx @@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js'; import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { IoInformationCircleOutline } from 'react-icons/io5'; +import SendNativeTokenButton from '@extension/components/SendNativeTokenButton'; // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; @@ -22,12 +23,14 @@ interface IProps { atomicBalance: BigNumber; minAtomicBalance: BigNumber; nativeCurrency: INativeCurrency; + publicKey: string } const NativeBalance: FC = ({ atomicBalance, minAtomicBalance, nativeCurrency, + publicKey }: IProps) => { const { t } = useTranslation(); const defaultTextColor: string = useDefaultTextColor(); @@ -82,6 +85,7 @@ const NativeBalance: FC = ({ })} + ); }; diff --git a/src/extension/components/SendNativeTokenButton/SendNativeTokenButton.tsx b/src/extension/components/SendNativeTokenButton/SendNativeTokenButton.tsx new file mode 100644 index 00000000..9569ec4b --- /dev/null +++ b/src/extension/components/SendNativeTokenButton/SendNativeTokenButton.tsx @@ -0,0 +1,56 @@ +import { Icon, IconButton, Tooltip } from '@chakra-ui/react'; +import React, { FC } from 'react'; +import { IoSendOutline } from 'react-icons/io5'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +// constants +import { ACCOUNTS_ROUTE, ASSETS_ROUTE } from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; + +// servcies +import { AccountService } from '@extension/services'; + +interface IProps { + ariaLabel: string; + toolTipLabel?: string; + size?: string; + publicKey: string; +} + +const SendNativeTokenButton: FC = ({ + ariaLabel, + toolTipLabel, + size = 'sm', + publicKey, +}: IProps) => { + const { t } = useTranslation(); + const buttonHoverBackgroundColor: string = useButtonHoverBackgroundColor(); + const defaultTextColor: string = useDefaultTextColor(); + + return ( + ('labels.sendNativeToken')} + placement="bottom" + > + + } + size={size} + variant="ghost" + as={Link} + to={`${ACCOUNTS_ROUTE}/${AccountService.convertPublicKeyToAlgorandAddress( + publicKey + )}${ASSETS_ROUTE}/${0}`} + /> + + ); +}; + +export default SendNativeTokenButton; diff --git a/src/extension/components/SendNativeTokenButton/index.ts b/src/extension/components/SendNativeTokenButton/index.ts new file mode 100644 index 00000000..82e81145 --- /dev/null +++ b/src/extension/components/SendNativeTokenButton/index.ts @@ -0,0 +1 @@ +export { default } from './SendNativeTokenButton'; \ No newline at end of file diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index 56f5d2bf..e174fb3e 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -125,9 +125,9 @@ const AccountPage: FC = () => { const accountInformation: IAccountInformation | null = account && selectedNetwork ? AccountService.extractAccountInformationForNetwork( - account, - selectedNetwork - ) + account, + selectedNetwork + ) : null; const accountTabId: number = parseInt( searchParams.get('accountTabId') || '0' @@ -267,6 +267,7 @@ const AccountPage: FC = () => { new BigNumber(accountInformation.minAtomicBalance) } nativeCurrency={selectedNetwork.nativeCurrency} + publicKey={account.publicKey} /> @@ -374,12 +375,11 @@ const AccountPage: FC = () => { // if there is no account, go to the first account, or the accounts index if no accounts exist if (!account) { navigate( - `${ACCOUNTS_ROUTE}${ - accounts[0] - ? `/${AccountService.convertPublicKeyToAlgorandAddress( - accounts[0].publicKey - )}` - : '' + `${ACCOUNTS_ROUTE}${accounts[0] + ? `/${AccountService.convertPublicKeyToAlgorandAddress( + accounts[0].publicKey + )}` + : '' }`, { replace: true, diff --git a/src/extension/pages/AssetPage/hooks/useAssetPage/useAssetPage.ts b/src/extension/pages/AssetPage/hooks/useAssetPage/useAssetPage.ts index d83523b7..0d45c5da 100644 --- a/src/extension/pages/AssetPage/hooks/useAssetPage/useAssetPage.ts +++ b/src/extension/pages/AssetPage/hooks/useAssetPage/useAssetPage.ts @@ -69,7 +69,30 @@ export default function useAssetPage({ useEffect(() => { let selectedAsset: IAsset | null; - if (assetId && assets.length > 0) { + if (assetId && assetId === "0") { + selectedAsset = { + clawbackAddress: null, + creator: "", + decimals: selectedNetwork?.nativeCurrency.decimals || 6, + defaultFrozen: false, + deleted: false, + freezeAddress: null, + iconUrl: selectedNetwork?.nativeCurrency.iconUri || null, + id: "0", + managerAddress: null, + metadataHash: null, + name: selectedNetwork?.canonicalName || null, + nameBase64: null, + reserveAddress: null, + total: "0", + unitName: selectedNetwork?.nativeCurrency.code || null, + unitNameBase64: null, + url: null, + urlBase64: null, + verified: true, + } + setAsset(selectedAsset); + } else if (assetId && assets.length > 0) { selectedAsset = assets.find((value) => value.id === assetId) || null; // if there is no asset, we have an error @@ -101,7 +124,16 @@ export default function useAssetPage({ (value) => value.id === assetId ) || null; - // if teh account does not have the asset holding, we have an error + // if the account does not have the asset holding, we have an error + + if (assetId && assetId == "0") { + selectedAssetHolding = { + amount: accountInformation.atomicBalance, + id: "0", + isFrozen: false, + } + } + if (!selectedAssetHolding) { return onError(); } diff --git a/yarn.lock b/yarn.lock index abec2923..25e4e3d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4083,6 +4083,21 @@ algo-msgpack-with-bigint@^2.1.1: resolved "https://registry.yarnpkg.com/algo-msgpack-with-bigint/-/algo-msgpack-with-bigint-2.1.1.tgz#38bb717220525b3ff42232eefdcd9efb9ad405d6" integrity sha512-F1tGh056XczEaEAqu7s+hlZUDWwOBT70Eq0lfMpBP2YguSQVyxRbprLq5rELXKQOyOaixTWYhMeMQMzP0U5FoQ== +algosdk@2.7.0, algosdk@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/algosdk/-/algosdk-2.7.0.tgz#6ebab130d25fc3cb4c74dce4d8753c7e86db1404" + integrity sha512-sBE9lpV7bup3rZ+q2j3JQaFAE9JwZvjWKX00vPlG8e9txctXbgLL56jZhSWZndqhDI9oI+0P4NldkuQIWdrUyg== + dependencies: + algo-msgpack-with-bigint "^2.1.1" + buffer "^6.0.3" + hi-base32 "^0.5.1" + js-sha256 "^0.9.0" + js-sha3 "^0.8.0" + js-sha512 "^0.8.0" + json-bigint "^1.0.0" + tweetnacl "^1.0.3" + vlq "^2.0.4" + algosdk@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/algosdk/-/algosdk-2.1.0.tgz#2d9538445a58f84d648836182f90985762715418" @@ -4182,6 +4197,21 @@ anymatch@^3.0.3, anymatch@~3.1.2: resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== +arc200js@^1.2.22: + version "1.2.22" + resolved "https://registry.yarnpkg.com/arc200js/-/arc200js-1.2.22.tgz#0743a776c6f03fb8366caae8000380d0e6d24e31" + integrity sha512-B9jo4oksk6ZFrT/rBungBgtqzRJDJadLsLf6xPcy7VD0ROWPm4kV2+Y4OsvryB9YPKaovAW0fUWnvk3dKHrtSQ== + dependencies: + arccjs "^1.1.4" + +arccjs@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/arccjs/-/arccjs-1.1.4.tgz#b2ea54d8313d1b54ba2b40818410da562d53bc2c" + integrity sha512-achg0rZfwATl42J2so+rRvwz5qYTTzhiI92MDDwfMZ7F3EInuAQco1kOyOJ6qXXC/CIqhH76UuZgPn3zqux2Fg== + dependencies: + algosdk "^2.7.0" + buffer "^6.0.3" + archy@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40"