From 39c31bf0fed11185b74c5cb68ac0273b9c761509 Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 11 Jul 2023 23:18:15 +0200 Subject: [PATCH 01/22] WIP: alby support --- components/activities/list.tsx | 4 +- components/assets/list.tsx | 11 +- components/assets/row.tsx | 8 +- components/balance/fiat.tsx | 6 +- components/balance/row.tsx | 4 +- components/balance/table.tsx | 4 +- components/borrow/button.tsx | 8 +- components/borrow/index.tsx | 8 +- components/buttons/connect.tsx | 59 +++---- components/channel/index.tsx | 4 +- components/contracts/list.tsx | 4 +- components/dropdown/index.tsx | 51 ++++++ components/investments/balance.tsx | 4 +- components/modals/redeem.tsx | 4 +- components/notifications/index.tsx | 8 +- components/providers/wallet.tsx | 202 ++++++++++------------ lib/constants.ts | 6 - lib/contracts.ts | 34 ++-- lib/hooks.ts | 35 ++++ lib/marina.ts | 264 +++++++++++++---------------- lib/wallet.ts | 35 ++++ package.json | 1 + yarn.lock | 5 + 23 files changed, 420 insertions(+), 349 deletions(-) create mode 100644 components/dropdown/index.tsx create mode 100644 lib/hooks.ts create mode 100644 lib/wallet.ts diff --git a/components/activities/list.tsx b/components/activities/list.tsx index 42be4550..d077f420 100644 --- a/components/activities/list.tsx +++ b/components/activities/list.tsx @@ -12,10 +12,10 @@ interface ActivitiesListProps { } const ActivitiesList = ({ activityType }: ActivitiesListProps) => { - const { connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { activities, loading } = useContext(ContractsContext) - if (!connected) + if (!wallet?._isConnected()) return ( 🔌 Connect your wallet to view your activities ) diff --git a/components/assets/list.tsx b/components/assets/list.tsx index 1f74266b..d6544626 100644 --- a/components/assets/list.tsx +++ b/components/assets/list.tsx @@ -4,9 +4,14 @@ import SomeError from 'components/layout/error' import AssetRow from './row' import Spinner from 'components/spinner' import { ConfigContext } from 'components/providers/config' +import { WalletContext } from 'components/providers/wallet' +import { useSelectBalances } from 'lib/hooks' +import { getAssetBalance } from 'lib/marina' const AssetsList = () => { const { config, loading } = useContext(ConfigContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) const [filteredAssets, setFilteredAssets] = useState() @@ -23,7 +28,11 @@ const AssetsList = () => {
{filteredAssets && filteredAssets.map((asset: Asset, index: number) => ( - + ))}
) diff --git a/components/assets/row.tsx b/components/assets/row.tsx index 32b7daff..7f832a35 100644 --- a/components/assets/row.tsx +++ b/components/assets/row.tsx @@ -3,19 +3,15 @@ import Image from 'next/image' import { Asset } from 'lib/types' import FilterButton from 'components/buttons/filter' import TradeButton from 'components/buttons/trade' -import { useContext } from 'react' -import { WalletContext } from 'components/providers/wallet' -import { getAssetBalance } from 'lib/marina' import ProgressBar from 'components/progress' import LeftToMint from 'components/progress/leftToMint' interface AssetRowProps { asset: Asset + balance: number } -const AssetRow = ({ asset }: AssetRowProps) => { - const { balances } = useContext(WalletContext) - const balance = getAssetBalance(asset, balances) +const AssetRow = ({ asset, balance }: AssetRowProps) => { const disabled = !(asset.isAvailable && asset.id) return ( diff --git a/components/balance/fiat.tsx b/components/balance/fiat.tsx index 1bac5147..005ea2cc 100644 --- a/components/balance/fiat.tsx +++ b/components/balance/fiat.tsx @@ -5,9 +5,11 @@ import { prettyNumber, prettyPercentage } from 'lib/pretty' import { getAssetBalance } from 'lib/marina' import { ContractsContext } from 'components/providers/contracts' import { ConfigContext } from 'components/providers/config' +import { useSelectBalances } from 'lib/hooks' const BalanceInFiat = () => { - const { balances, connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) const { config } = useContext(ConfigContext) const { loading } = useContext(ContractsContext) @@ -28,7 +30,7 @@ const BalanceInFiat = () => { ) }, [assets, balances]) - if (!connected) return

🔌 Connect your wallet to view your balance

+ if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

if (loading) return return ( diff --git a/components/balance/row.tsx b/components/balance/row.tsx index bb9e2f67..e5fcbb9a 100644 --- a/components/balance/row.tsx +++ b/components/balance/row.tsx @@ -4,12 +4,14 @@ import { Asset } from 'lib/types' import { useContext } from 'react' import { getAssetBalance } from 'lib/marina' import { WalletContext } from 'components/providers/wallet' +import { useSelectBalances } from 'lib/hooks' interface BalanceRowProps { asset: Asset } const BalanceRow = ({ asset }: BalanceRowProps) => { - const { balances } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) return ( diff --git a/components/balance/table.tsx b/components/balance/table.tsx index 002abd74..fb1087d9 100644 --- a/components/balance/table.tsx +++ b/components/balance/table.tsx @@ -6,13 +6,13 @@ import { ContractsContext } from 'components/providers/contracts' import { ConfigContext } from 'components/providers/config' const BalanceTable = () => { - const { connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { config } = useContext(ConfigContext) const { loading } = useContext(ContractsContext) const { assets } = config - if (!connected) return

🔌 Connect your wallet to view your balance

+ if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

if (loading) return return ( diff --git a/components/borrow/button.tsx b/components/borrow/button.tsx index ad9efedc..b515d61e 100644 --- a/components/borrow/button.tsx +++ b/components/borrow/button.tsx @@ -2,6 +2,7 @@ import { ConfigContext } from 'components/providers/config' import { ContractsContext } from 'components/providers/contracts' import { WalletContext } from 'components/providers/wallet' import { feeAmount, minDustLimit } from 'lib/constants' +import { useSelectBalances } from 'lib/hooks' import { getAssetBalance } from 'lib/marina' import { swapDepositAmountOutOfBounds } from 'lib/swaps' import { LightningEnabledTasks, Tasks } from 'lib/tasks' @@ -17,7 +18,8 @@ interface BorrowButtonProps { } const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => { - const { balances, connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) const { config } = useContext(ConfigContext) const { setNewContract } = useContext(ContractsContext) @@ -33,7 +35,7 @@ const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => { if (!asset) return const funds = getAssetBalance(asset, balances) const needed = contract.collateral.quantity - const enoughFundsOnMarina = connected && funds > needed + const enoughFundsOnMarina = !!wallet && wallet.isConnected() && funds > needed if (LightningEnabledTasks[Tasks.Borrow]) { const outOfBounds = swapDepositAmountOutOfBounds(needed) setEnoughFunds(enoughFundsOnMarina || !outOfBounds) @@ -58,7 +60,7 @@ const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => { }, [contract.synthetic.quantity]) const enabled = - connected && + wallet?.isConnected() && enoughFunds && collateral.quantity > 0 && collateral.value > 0 && diff --git a/components/borrow/index.tsx b/components/borrow/index.tsx index 3b71a946..d311f10e 100644 --- a/components/borrow/index.tsx +++ b/components/borrow/index.tsx @@ -1,4 +1,4 @@ -import { Contract, Offer, Oracle } from 'lib/types' +import { Contract, Offer } from 'lib/types' import BorrowForm from './form' import { useContext, useEffect, useState } from 'react' import BorrowInfo from './info' @@ -19,7 +19,7 @@ interface BorrowProps { } const Borrow = ({ offer }: BorrowProps) => { - const { network, xPubKey } = useContext(WalletContext) + const { network, wallet, wallets } = useContext(WalletContext) const { newContract } = useContext(ContractsContext) const { collateral, oracles, synthetic } = offer @@ -30,6 +30,8 @@ const Borrow = ({ offer }: BorrowProps) => { const minRatio = synthetic.minCollateralRatio || minBorrowRatio const priceLevel = getContractPriceLevel(offer, startingRatio) + if (!wallet) throw new Error('Wallet not found') + const [contract, setContract] = useState({ collateral, expirationDate: getContractExpirationDate(), @@ -37,7 +39,7 @@ const Borrow = ({ offer }: BorrowProps) => { oracles, synthetic, priceLevel, - xPubKey, + xPubKey: wallet.getMainAccountXPubKey() }) useEffect(() => { diff --git a/components/buttons/connect.tsx b/components/buttons/connect.tsx index 8bc24649..0ffd5e6e 100644 --- a/components/buttons/connect.tsx +++ b/components/buttons/connect.tsx @@ -1,58 +1,47 @@ import { useContext } from 'react' import { WalletContext } from 'components/providers/wallet' -import { createFujiAccount, fujiAccountMissing } from 'lib/marina' -import { closeModal, openModal } from 'lib/utils' -import AccountModal from 'components/modals/account' -import WalletsModal from 'components/modals/wallets' -import { ModalIds } from 'components/modals/modal' +import { WalletType } from 'lib/wallet' +import DropDown from 'components/dropdown' const ConnectButton = () => { - const { connected, marina, setConnected } = useContext(WalletContext) + const { wallet, selectWallet, wallets } = useContext(WalletContext) - const toggle = async () => { - if (!marina) return - if (connected) { - await marina.disable() - setConnected(false) - } else { - openModal(ModalIds.Wallets) + const toggleWallet = async (type: WalletType) => { + if (!type) return + // disconnect if already connected + if (wallet?.type === type) { + if (wallet?.isConnected()) { + await wallet.disconnect() + } + return } - } - const handleWalletChoice = async () => { - closeModal(ModalIds.Wallets) - if (!marina) return - if (!(await marina.isEnabled())) await marina.enable() - setConnected(true) - if (await fujiAccountMissing(marina)) { - openModal(ModalIds.Account) - await createFujiAccount(marina) - closeModal(ModalIds.Account) - } + // select the new wallet and connect it + await selectWallet(type) + await wallet?.connect() } return ( <> - {marina && ( + {wallets?.length && ( <> - - - + ({ + value: wallet?.type === w.type ? 'Disconnect ' + wallet?.type : w.type, + onClick: () => toggleWallet(w.type), + }))} + /> )} - {!marina && ( + {!wallets.length && ( - Install Marina + Install Marina or Alby )} diff --git a/components/channel/index.tsx b/components/channel/index.tsx index 9f781de4..1d4954c7 100644 --- a/components/channel/index.tsx +++ b/components/channel/index.tsx @@ -24,8 +24,8 @@ interface ChannelProps { } const Channel = ({ amount, contract, task }: ChannelProps) => { - const { marina } = useContext(WalletContext) - if (!marina) throw new Error('Missing marina provider') + const { wallet } = useContext(WalletContext) + if (!wallet) throw new Error('Missing marina provider') const { collateral } = contract const ticker = collateral.ticker diff --git a/components/contracts/list.tsx b/components/contracts/list.tsx index 5224aef7..bd815f8d 100644 --- a/components/contracts/list.tsx +++ b/components/contracts/list.tsx @@ -12,7 +12,7 @@ interface ContractsListProps { } const ContractsList = ({ showActive }: ContractsListProps) => { - const { connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { contracts, loading } = useContext(ContractsContext) const [filteredContracts, setFilteredContracts] = useState([]) @@ -25,7 +25,7 @@ const ContractsList = ({ showActive }: ContractsListProps) => { ) }, [contracts, showActive]) - if (!connected) + if (!wallet?._isConnected()) return ( 🔌 Connect your wallet to view your contracts ) diff --git a/components/dropdown/index.tsx b/components/dropdown/index.tsx new file mode 100644 index 00000000..1e135f84 --- /dev/null +++ b/components/dropdown/index.tsx @@ -0,0 +1,51 @@ +import classNames from 'classnames' +import React from 'react' + +export type DropDownProps = { + title: string + options: { + value: string + onClick: () => Promise + }[] +} + +const DropDown: React.FC = ({ title, options }) => { + const [isActive, setIsActive] = React.useState(false) + + return ( +
+
+ +
+ +
+ ) +} + +export default DropDown diff --git a/components/investments/balance.tsx b/components/investments/balance.tsx index ec3b3de3..ebc718aa 100644 --- a/components/investments/balance.tsx +++ b/components/investments/balance.tsx @@ -4,9 +4,9 @@ import InvestButton from './button' import BalanceInFiat from 'components/balance/fiat' const TotalBalance = () => { - const { connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) - if (!connected) return

🔌 Connect your wallet to view your balance

+ if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

return (
diff --git a/components/modals/redeem.tsx b/components/modals/redeem.tsx index 6a2e0c8c..63c2183b 100644 --- a/components/modals/redeem.tsx +++ b/components/modals/redeem.tsx @@ -7,6 +7,7 @@ import Result from 'components/result' import { useContext } from 'react' import { WalletContext } from 'components/providers/wallet' import { getAssetBalance } from 'lib/marina' +import { useSelectBalances } from 'lib/hooks' interface RedeemModalProps { contract: Contract @@ -27,7 +28,8 @@ const RedeemModal = ({ stage, task, }: RedeemModalProps) => { - const { balances } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) if (!contract) return <> diff --git a/components/notifications/index.tsx b/components/notifications/index.tsx index ac2830e9..21cefee6 100644 --- a/components/notifications/index.tsx +++ b/components/notifications/index.tsx @@ -16,6 +16,7 @@ import { LightningEnabledTasks, Tasks } from 'lib/tasks' import { ConfigContext } from 'components/providers/config' import MintLimitReachedNotification from './mintLimitReached' import { fromSatoshis } from 'lib/utils' +import { useSelectBalances } from 'lib/hooks' interface NotificationsProps { contract: Contract @@ -30,7 +31,8 @@ const Notifications = ({ ratio, topup, }: NotificationsProps) => { - const { balances, connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + const balances = useSelectBalances(wallet) const { config } = useContext(ConfigContext) const [belowDustLimit, setBelowDustLimit] = useState(false) @@ -52,7 +54,7 @@ const Notifications = ({ const asset = assets.find((a) => a.ticker === collateral.ticker) if (!asset) return const balance = getAssetBalance(asset, balances) - setNotEnoughFunds(connected && spendQuantity > balance) + setNotEnoughFunds(!!wallet?.isConnected() && spendQuantity > balance) setOutOfBounds(swapDepositAmountOutOfBounds(spendQuantity)) setCollateralTooLow(spendQuantity < feeAmount + minDustLimit) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -96,7 +98,7 @@ const Notifications = ({ ) : ( <>{notEnoughFunds && } )} - {!connected && } + {!wallet?.isConnected() && } {belowDustLimit && } {collateralTooLow && } {notEnoughOracles && } diff --git a/components/providers/wallet.tsx b/components/providers/wallet.tsx index 0358ec9a..759956ad 100644 --- a/components/providers/wallet.tsx +++ b/components/providers/wallet.tsx @@ -1,142 +1,112 @@ -import { createContext, ReactNode, useEffect, useRef, useState } from 'react' import { - getBalances, - getMarinaProvider, - getNetwork, - getMainAccountXPubKey, -} from 'lib/marina' -import { Balance, MarinaProvider, NetworkString } from 'marina-provider' + createContext, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { defaultNetwork } from 'lib/constants' import { ChainSource, WsElectrumChainSource } from 'lib/chainsource.port' +import { Wallet, WalletType } from 'lib/wallet' +import { MarinaWallet } from 'lib/marina' +import { NetworkString } from 'marina-provider' interface WalletContextProps { - balances: Balance[] - chainSource: ChainSource - connected: boolean - marina: MarinaProvider | undefined - network: NetworkString | undefined - setConnected: (arg0: boolean) => void - updateBalances: () => void - xPubKey: string + wallets: Wallet[] + chainSource?: ChainSource + network: NetworkString + + wallet?: Wallet; + selectWallet(type: WalletType): Promise +} + +function walletFactory(type: WalletType): Promise { + switch (type) { + case WalletType.Marina: + return MarinaWallet.detect() + case WalletType.Alby: + throw new Error('Alby wallet not implemented') + default: + throw new Error('Unknown wallet type') + } +} + +async function detectWallets(): Promise { + const supportedWallets = [WalletType.Marina] + const walletsPromises = await Promise.allSettled( + supportedWallets.map((type) => walletFactory(type)), + ) + + const wallets = [] + for (const walletPromise of walletsPromises) { + if (walletPromise.status === 'fulfilled' && walletPromise.value) { + wallets.push(walletPromise.value) + } + } + return wallets } export const WalletContext = createContext({ - balances: [], - chainSource: new WsElectrumChainSource(defaultNetwork), - connected: false, - marina: undefined, - network: undefined, - setConnected: () => {}, - updateBalances: () => {}, - xPubKey: '', + wallets: [], + wallet: undefined, + selectWallet: () => Promise.resolve(), + network: defaultNetwork, }) interface WalletProviderProps { children: ReactNode } -export const WalletProvider = ({ children }: WalletProviderProps) => { - const [balances, setBalances] = useState([]) - const [chainSource, setChainSource] = useState( - new WsElectrumChainSource(defaultNetwork), - ) - const [connected, setConnected] = useState(false) - const [marina, setMarina] = useState() - const [network, setNetwork] = useState() - const [xPubKey, setXPubKey] = useState('') - const updateBalances = async () => setBalances(await getBalances()) - const updateNetwork = async () => setNetwork(await getNetwork()) - const updateXPubKey = async () => setXPubKey(await getMainAccountXPubKey()) - - // get marina provider - useEffect(() => { - getMarinaProvider().then((marinaProvider) => { - setMarina(marinaProvider) - updateNetwork() - }) - }, []) +export const WalletProvider = ({ children }: WalletProviderProps) => { + const [wallets, setWallets] = useState([]) + const [wallet, setWallet] = useState(undefined) + const [closeNetworkListener, setCloseNetworkLst] = useState<() => void>() + const [chainSource, setChainSource] = useState<{ + network: string + src: ChainSource + }>() - // update connected state - useEffect(() => { - if (marina) { - marina.isEnabled().then((payload) => setConnected(payload)) - } else { - setConnected(false) - } - }, [marina]) + const selectWallet = useCallback( + async (type: WalletType) => { + const wallet = wallets.find((wallet) => wallet.type === type) + if (!wallet) throw new Error(`Wallet ${type} not found`) + setWallet(wallet) + // close previous listeners + closeNetworkListener?.() + chainSource?.src.close() // close chain source - // add event listeners for enable and disable (aka connected) - useEffect(() => { - if (marina && network) { - const onDisabledId = marina.on('DISABLED', ({ data }) => { - if (data.network === network) setConnected(false) - }) - const onEnabledId = marina.on('ENABLED', ({ data }) => { - if (data.network === network) setConnected(true) + const closeOnNetworkChange = wallet.onNetworkChange((network) => { + if (network !== chainSource?.network) { + closeNetworkListener?.() + chainSource?.src.close().catch(console.error) + const src = new WsElectrumChainSource(network) + setChainSource({ + network, + src, + }) + } }) - return () => { - marina.off(onDisabledId) - marina.off(onEnabledId) - } - } - }, [marina, network]) - - // update network and add event listener - useEffect(() => { - if (connected && marina) { - const id = marina.on('NETWORK', updateNetwork) - return () => marina.off(id) - } - }, [connected, marina]) - - // when network changes, connect to respective electrum server - useEffect(() => { - if (network && chainSource.network !== network) { - chainSource - .close() - .then(() => { - setChainSource(new WsElectrumChainSource(network)) - }) - .catch(console.error) - } - }, [chainSource, network]) + setCloseNetworkLst(() => closeOnNetworkChange) + }, + [wallets, chainSource, closeNetworkListener], + ) - // update balances and add event listener useEffect(() => { - // marina can take up to 10 seconds to update balances - // so web update balances now and on 10 seconds in the future - const updateNowAndLater = () => { - updateBalances() - setTimeout(updateBalances, 10_000) - } - // add event listeners - if (connected && marina) { - marina.isEnabled().then((enabled) => { - if (enabled) { - updateBalances() - updateXPubKey() - const onSpentUtxoId = marina.on('SPENT_UTXO', updateNowAndLater) - const onNewUtxoId = marina.on('NEW_UTXO', updateNowAndLater) - return () => { - marina.off(onSpentUtxoId) - marina.off(onNewUtxoId) - } - } - }) - } - }, [connected, marina, network]) + detectWallets() + .then(setWallets) + .then(() => selectWallet(WalletType.Marina)) // select marina by default + .catch(console.error) + }, [selectWallet]) return ( {children} diff --git a/lib/constants.ts b/lib/constants.ts index 5f2f73d6..85f0a399 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -6,12 +6,6 @@ export const safeBorrowMargin = 50 export const minMultiplyRatio = 130 export const maxMultiplyRatio = 330 -// marina account IDs -export const marinaFujiAccountID = 'fuji' // slip13(fuji) -export const marinaMainAccountID = 'mainAccount' // m/84'/1776'/0' -export const marinaTestnetMainAccountID = 'mainAccountTest' // m/84'/1'/0' -export const marinaLegacyMainAccountID = 'mainAccountLegacy' // m/44'/0'/0' - export const defaultNetwork = 'liquid' export const treasuryPublicKey = diff --git a/lib/contracts.ts b/lib/contracts.ts index c4b10491..c651cc7e 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -1,7 +1,6 @@ import { ActivityType, Asset, Contract, ContractState } from './types' import Decimal from 'decimal.js' import { expirationSeconds, safeBorrowMargin } from './constants' -import { getNetwork, getMainAccountXPubKey } from './marina' import { updateContractOnStorage, addContractToStorage, @@ -14,6 +13,7 @@ import { fromSatoshis, hex64LEToNumber, toSatoshis } from './utils' import { ChainSource } from './chainsource.port' import { address, Transaction } from 'liquidjs-lib' import { Artifact } from '@ionio-lang/ionio' +import { Wallet } from './wallet' // checks if a given contract was already spent // 1. fetch the contract funding transaction @@ -62,19 +62,18 @@ export async function checkContractOutspend( } // transform a fuji coin into a contract -export const coinToContract = async ( +export function coinToContract( + network: NetworkString, coin: Utxo, - assets: Asset[], -): Promise => { + synthetic: Asset, + collateral: Asset, +): Contract | undefined { if ( coin.scriptDetails && isIonioScriptDetails(coin.scriptDetails) && coin.blindingData ) { const params = coin.scriptDetails.params - const collateral = assets.find((a) => a.id === coin.blindingData?.asset) - const synthetic = assets.find((a) => a.id === (params[0] as string)) - if (!collateral || !synthetic) return const borrowAsset = params[0] as string const borrowAmount = params[1] as number const treasuryPublicKey = params[2] as string @@ -103,7 +102,7 @@ export const coinToContract = async ( }, createdAt, expirationDate: getContractExpirationDate(Math.floor(createdAt / 1000)), - network: await getNetwork(), + network, oracles: [oraclePublicKey], priceLevel: hex64LEToNumber(priceLevel), synthetic: { @@ -203,11 +202,11 @@ export const getContractPriceLevel = ( // get all contacts belonging to this xpub and network export async function getContracts( + xPubKey: string, // xpub of the wallet (wallet ID) assets: Asset[], network: NetworkString, ): Promise { if (typeof window === 'undefined' || assets.length === 0) return [] - const xPubKey = await getMainAccountXPubKey() const contracts: Contract[] = [] for (const contract of getMyContractsFromStorage(network, xPubKey)) { const collateral = assets.find((a) => a.id === contract.collateral.id) @@ -231,11 +230,20 @@ export async function getContracts( // get contract with txid export async function getContract( txid: string, - assets: Asset[], - network: NetworkString, + wallet: Wallet, + synthetic: Asset, + collateral: Asset, ): Promise { - const contracts = await getContracts(assets, network) - return contracts.find((c) => c.txid === txid) + const coins = await wallet.getCoins() + const network = await wallet.getNetwork() + const coin = coins.find((c) => c.txid === txid) + if (!coin) return + return coinToContract( + network, + coin, + synthetic, + collateral, + ) } // add contract to storage and create activity diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 00000000..9d4a8651 --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { Wallet } from './wallet' +import { Balance } from 'marina-provider' + +export const useSelectBalances = (wallet?: Wallet) => { + const [balances, setBalances] = useState([]) + + useEffect(() => { + if (!wallet) { + setBalances([]) + return + } + const getBalances = async () => { + const balances = await wallet.getBalances() + setBalances(balances) + } + + getBalances().catch(console.error) + + const closeOnNewUtxo = wallet.onNewUtxo(() => { + getBalances().catch(console.error) + }) + + const closeOnSpentUtxo = wallet.onSpentUtxo(() => { + getBalances().catch(console.error) + }) + + return () => { + closeOnNewUtxo() + closeOnSpentUtxo() + } + }, [wallet]) + + return balances +} diff --git a/lib/marina.ts b/lib/marina.ts index 6d0dd87f..c0c8044c 100644 --- a/lib/marina.ts +++ b/lib/marina.ts @@ -1,7 +1,4 @@ -import * as ecc from 'tiny-secp256k1' -import { BIP32Factory } from 'bip32' -import { Pset } from 'liquidjs-lib' -import { Asset, Contract, ContractParams } from './types' +import { Asset, ContractParams } from './types' import { detectProvider, MarinaProvider, @@ -12,179 +9,148 @@ import { AccountType, AccountID, Address, - SentTransaction, } from 'marina-provider' -import { - defaultNetwork, - marinaFujiAccountID, - marinaLegacyMainAccountID, - marinaMainAccountID, - marinaTestnetMainAccountID, -} from 'lib/constants' -import { coinToContract } from './contracts' import { Artifact } from '@ionio-lang/ionio' +import { Wallet, WalletType } from './wallet' +import { closeModal, openModal } from './utils' +import { ModalIds } from 'components/modals/modal' + +export class MarinaWallet implements Wallet { + static FujiAccountID = 'fuji' // slip13(fuji) + static MainAccountID = 'mainAccount' // m/84'/1776'/0' + static TestnetMainAccountID = 'mainAccountTest' // m/84'/1'/0' + static LegacyMainAccountID = 'mainAccountLegacy' // m/44'/0'/0' + + type = WalletType.Marina + _isConnected = false + _xPub: string | undefined // always main account, is the "ID" of the marina wallet + + private constructor(private marina: MarinaProvider) {} + + static async detect(): Promise { + const marina = await detectProvider('marina') + if (!marina) return undefined + const instance = new MarinaWallet(marina) + instance._isConnected = await instance.marina.isEnabled() + if (!instance._isConnected) await instance.connect() + const infos = await instance.marina.getAccountInfo(MarinaWallet.MainAccountID) + instance._xPub = infos.masterXPub + return instance + } -export async function getBalances(): Promise { - const marina = await getMarinaProvider() - if (!marina) return [] - if (!(await marina.isEnabled())) return [] - const mainAccountIDs = await getMainAccountIDs() - return marina.getBalances(mainAccountIDs) -} + isConnected(): boolean { + return this._isConnected + } -export function getAssetBalance(asset: Asset, balances: Balance[]): number { - const found = balances.find((a) => a.asset.assetHash === asset.id) - if (!found || !found.amount) return 0 - return found.amount -} + async connect(): Promise { + await this.marina.enable() + + if (await fujiAccountMissing(this.marina)) { + openModal(ModalIds.Account) + await createFujiAccount(this.marina) + closeModal(ModalIds.Account) + } -export async function getMarinaProvider(): Promise { - if (typeof window === 'undefined') return undefined - try { - return await detectProvider('marina') - } catch { - console.info('Please install Marina extension') - return undefined + this._isConnected = true } -} -export async function getNetwork(): Promise { - const marina = await getMarinaProvider() - if (marina) return await marina.getNetwork() - return defaultNetwork -} + async disconnect(): Promise { + await this.marina.disable() + this._isConnected = false + } -// always returns MainAccountID xpubkey -// if you want the testnet xpubkey, use marina.getAccountInfo(marinaMainTestnetAccountID) -export async function getMainAccountXPubKey(): Promise { - const marina = await getMarinaProvider() - if (marina) { - const info = await marina.getAccountInfo(marinaMainAccountID) - return info.masterXPub + getMainAccountXPubKey(): string { + return this._xPub ?? '' } - return '' -} -async function getCoins(accountID: string): Promise { - const marina = await getMarinaProvider() - if (!marina) return [] - return await marina.getCoins([accountID]) -} + async getBalances(): Promise { + if (!this._isConnected) return [] + const network = await this.getNetwork() + const mainAccountIDs = await getMainAccountIDs(network) + return this.marina.getBalances(mainAccountIDs) + } -export async function getMainAccountCoins(): Promise { - const marina = await getMarinaProvider() - if (!marina) return [] - const mainAccountIDs = await getMainAccountIDs() - return await marina.getCoins(mainAccountIDs) -} + getCoins(): Promise { + return this.marina.getCoins() + } -export async function getFujiCoins(): Promise { - return getCoins(marinaFujiAccountID) -} + getTransactions(): Promise { + return this.marina.getTransactions() + } -export async function getTransactions(): Promise { - const marina = await getMarinaProvider() - if (!marina) return [] - return marina.getTransactions() -} + getNetwork(): Promise { + return this.marina.getNetwork() + } -export async function signTx(partialTransaction: string) { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') + signPset(psetBase64: string): Promise { + return this.marina.signTransaction(psetBase64) + } - // sign transaction - const ptx = Pset.fromBase64(partialTransaction) - return await marina.signTransaction(ptx.toBase64()) -} + async getNextAddress(): Promise
{ + const network = await this.getNetwork() + const account = network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID + await this.marina.useAccount(account) + return this.marina.getNextAddress() + } -export async function createFujiAccount(marina: MarinaProvider) { - await marina.createAccount(marinaFujiAccountID, AccountType.Ionio) -} + async getNextChangeAddress(): Promise
{ + const network = await this.getNetwork() + const account = network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID + await this.marina.useAccount(account) + return this.marina.getNextChangeAddress() + } -export async function fujiAccountMissing( - marina: MarinaProvider, -): Promise { - const accountIDs = await marina.getAccountsIDs() - return !accountIDs.includes(marinaFujiAccountID) -} + async getNextCovenantAddress( + artifact: Artifact, + params: Omit, + ): Promise
{ + await this.marina.useAccount(MarinaWallet.FujiAccountID) + const covenantAddress = await this.marina.getNextAddress({ + artifact, + args: params, + }) + return covenantAddress + } -export async function getNextAddress(accountID?: AccountID) { - const marina = await getMarinaProvider() - if (!marina) throw new Error('No Marina provider found') - const mainAccountIDs = await getMainAccountIDs(false) - const id = accountID ?? mainAccountIDs[0] - await marina.useAccount(id) - const address = await marina.getNextAddress() - if (id !== mainAccountIDs[0]) await marina.useAccount(mainAccountIDs[0]) - return address + onSpentUtxo(callback: (utxo: Utxo) => void): () => void { + const id = this.marina.on('SPENT_UTXO', callback) + return () => this.marina.off(id) + } + + onNewUtxo(callback: (utxo: Utxo) => void): () => void { + const id = this.marina.on('NEW_UTXO', callback) + return () => this.marina.off(id) + } + + onNetworkChange(callback: (network: NetworkString) => void): () => void { + const id = this.marina.on('NETWORK', callback) + return () => this.marina.off(id) + } } -export async function getNextChangeAddress(accountID?: AccountID) { - const marina = await getMarinaProvider() - if (!marina) throw new Error('No Marina provider found') - const mainAccountIDs = await getMainAccountIDs(false) - const id = accountID ?? mainAccountIDs[0] - await marina.useAccount(id) - const address = await marina.getNextChangeAddress() - if (id !== mainAccountIDs[0]) await marina.useAccount(mainAccountIDs[0]) - return address +export function getAssetBalance(asset: Asset, balances: Balance[]): number { + const found = balances.find((a) => a.asset.assetHash === asset.id) + if (!found || !found.amount) return 0 + return found.amount } -export async function getNextCovenantAddress( - artifact: Artifact, - contractParams: Omit, -) { - const marina = await getMarinaProvider() - if (!marina) throw new Error('No Marina provider found') - await marina.useAccount(marinaFujiAccountID) - const covenantAddress = await marina.getNextAddress({ - artifact, - args: contractParams, - }) - await marina.useAccount((await getMainAccountIDs(false))[0]) - return covenantAddress +export async function createFujiAccount(marina: MarinaProvider) { + await marina.createAccount(MarinaWallet.FujiAccountID, AccountType.Ionio) } -export async function getPublicKey(covenantAddress: Address): Promise { - const marina = await getMarinaProvider() - if (!marina) throw new Error('No Marina provider found') - const { masterXPub } = await marina.getAccountInfo( - covenantAddress.accountName, - ) - if (!covenantAddress.derivationPath) - throw new Error( - 'unable to find derivation path used by Marina to generate borrowerPublicKey', - ) - return BIP32Factory(ecc) - .fromBase58(masterXPub) - .derivePath(covenantAddress.derivationPath.replace('m/', '')).publicKey // remove m/ from path +export async function fujiAccountMissing( + marina: MarinaProvider, +): Promise { + const accountIDs = await marina.getAccountsIDs() + return !accountIDs.includes(MarinaWallet.FujiAccountID) } -export async function getMainAccountIDs( +async function getMainAccountIDs( + network: NetworkString, withLegacy = true, ): Promise { - const network = await getNetwork() - const mainAccounts = withLegacy ? [marinaLegacyMainAccountID] : [] + const mainAccounts = withLegacy ? [MarinaWallet.LegacyMainAccountID] : [] return mainAccounts.concat( - network === 'liquid' ? marinaMainAccountID : marinaTestnetMainAccountID, + network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID, ) } - -export async function broadcastTx(rawTxHex: string): Promise { - const marina = await getMarinaProvider() - if (!marina) throw new Error('No Marina provider found') - return marina.broadcastTransaction(rawTxHex) -} - -export async function getContractsFromMarina( - assets: Asset[], -): Promise { - const contracts: Contract[] = [] - const coins = await getFujiCoins() - for (const coin of coins) { - const contract = await coinToContract(coin, assets) - if (contract) contracts.push(contract) - } - return contracts -} diff --git a/lib/wallet.ts b/lib/wallet.ts new file mode 100644 index 00000000..857e72f0 --- /dev/null +++ b/lib/wallet.ts @@ -0,0 +1,35 @@ +import { Artifact } from '@ionio-lang/ionio'; +import type { Address, Balance, NetworkString, Transaction, Utxo } from 'marina-provider' +import { ContractParams } from './types'; + +export enum WalletType { + Marina = 'marina', + Alby = 'alby', +} + +export interface Wallet { + type: WalletType; + isConnected(): boolean; + + connect(): Promise; + disconnect(): Promise; + + getMainAccountXPubKey(): string; + getBalances(): Promise; + getCoins(): Promise; + getTransactions(): Promise; + getNetwork(): Promise; + + getNextAddress(): Promise
; + getNextChangeAddress(): Promise
; + getNextCovenantAddress( + artifact: Artifact, + params: Omit // wallet should "inject" the borrower public key parameter + ): Promise
; + + signPset(psetBase64: string): Promise; + + onSpentUtxo(callback: (utxo: Utxo) => void): () => void; + onNewUtxo(callback: (utxo: Utxo) => void): () => void; + onNetworkChange(callback: (network: NetworkString) => void): () => void; +} diff --git a/package.json b/package.json index 62788a27..1c514926 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "bolt11": "^1.4.0", "bs58check": "^3.0.1", "bulma": "^0.9.3", + "classnames": "^2.3.2", "cookies": "^0.8.0", "decimal.js": "^10.3.1", "file-saver": "^2.0.5", diff --git a/yarn.lock b/yarn.lock index 70a0d785..f30a4226 100644 --- a/yarn.lock +++ b/yarn.lock @@ -726,6 +726,11 @@ classnames@^2.2.5: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== +classnames@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" From 1d1d207045731a0baf9bf5a81a09bef1503dabf5 Mon Sep 17 00:00:00 2001 From: Louis Singer Date: Wed, 12 Jul 2023 17:40:13 +0200 Subject: [PATCH 02/22] feat: abstract wallet behavior --- components/activities/list.tsx | 2 +- components/activities/row.tsx | 2 +- components/balance/fiat.tsx | 3 +- components/balance/table.tsx | 3 +- components/banner/index.tsx | 10 +- components/borrow/button.tsx | 16 ++- components/borrow/index.tsx | 13 +- components/buttons/connect.tsx | 19 ++- components/contracts/list.tsx | 2 +- components/contracts/row.tsx | 6 +- components/dropdown/index.tsx | 23 ++- components/investments/balance.tsx | 3 +- components/investments/index.tsx | 13 +- components/investments/list.tsx | 10 +- components/links/explorer.tsx | 8 +- components/modals/account.tsx | 5 +- components/multiply/deposit.tsx | 4 +- components/providers/config.tsx | 10 +- components/providers/contracts.tsx | 115 +++++++++------ components/providers/wallet.tsx | 40 +----- components/providers/webln.tsx | 6 +- components/result/index.tsx | 4 +- components/stocks/list.tsx | 10 +- components/topup/button.tsx | 4 +- lib/activities.ts | 8 +- lib/chainsource.port.ts | 6 + lib/contracts.ts | 11 +- lib/covenant.ts | 156 +++++++-------------- lib/hooks.ts | 2 + lib/marina.ts | 94 ++++++++++--- lib/selection.ts | 17 ++- lib/utils.ts | 2 - lib/wallet.ts | 57 +++++--- pages/_app.tsx | 5 + pages/borrow/[...params].tsx | 47 ++++--- pages/contracts/[txid]/close/channel.tsx | 21 ++- pages/contracts/[txid]/close/lightning.tsx | 21 +-- pages/contracts/[txid]/close/liquid.tsx | 19 +-- pages/contracts/[txid]/topup/index.tsx | 21 ++- pages/contracts/[txid]/topup/lightning.tsx | 19 +-- pages/contracts/[txid]/topup/liquid.tsx | 22 +-- public/images/wallets/alby.svg | 17 +++ 42 files changed, 511 insertions(+), 365 deletions(-) create mode 100644 public/images/wallets/alby.svg diff --git a/components/activities/list.tsx b/components/activities/list.tsx index d077f420..c17b4a74 100644 --- a/components/activities/list.tsx +++ b/components/activities/list.tsx @@ -15,7 +15,7 @@ const ActivitiesList = ({ activityType }: ActivitiesListProps) => { const { wallet } = useContext(WalletContext) const { activities, loading } = useContext(ContractsContext) - if (!wallet?._isConnected()) + if (!wallet?.isConnected()) return ( 🔌 Connect your wallet to view your activities ) diff --git a/components/activities/row.tsx b/components/activities/row.tsx index 82c4750e..d8ae7765 100644 --- a/components/activities/row.tsx +++ b/components/activities/row.tsx @@ -26,7 +26,7 @@ const ActivityRow = ({ activity }: ActivityRowProps) => {
- +

{prettyAgo(createdAt)}

diff --git a/components/balance/fiat.tsx b/components/balance/fiat.tsx index 005ea2cc..f645a18b 100644 --- a/components/balance/fiat.tsx +++ b/components/balance/fiat.tsx @@ -30,7 +30,8 @@ const BalanceInFiat = () => { ) }, [assets, balances]) - if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

+ if (!wallet?.isConnected()) + return

🔌 Connect your wallet to view your balance

if (loading) return return ( diff --git a/components/balance/table.tsx b/components/balance/table.tsx index fb1087d9..45ab1724 100644 --- a/components/balance/table.tsx +++ b/components/balance/table.tsx @@ -12,7 +12,8 @@ const BalanceTable = () => { const { assets } = config - if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

+ if (!wallet?.isConnected()) + return

🔌 Connect your wallet to view your balance

if (loading) return return ( diff --git a/components/banner/index.tsx b/components/banner/index.tsx index 25abb8b9..bdc3de58 100644 --- a/components/banner/index.tsx +++ b/components/banner/index.tsx @@ -1,8 +1,14 @@ import { WalletContext } from 'components/providers/wallet' -import { useContext } from 'react' +import { useContext, useEffect, useState } from 'react' export default function Banner() { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) + + const [network, setNetwork] = useState('liquid') + + useEffect(() => { + if (wallet) wallet.getNetwork().then((n) => setNetwork(n)) + }, [wallet]) if (network !== 'testnet') return <> diff --git a/components/borrow/button.tsx b/components/borrow/button.tsx index b515d61e..5d105c7c 100644 --- a/components/borrow/button.tsx +++ b/components/borrow/button.tsx @@ -31,19 +31,24 @@ const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => { const { assets } = config useEffect(() => { + if (balances.length === 0) return const asset = assets.find((a) => a.ticker === contract.collateral.ticker) if (!asset) return const funds = getAssetBalance(asset, balances) const needed = contract.collateral.quantity - const enoughFundsOnMarina = !!wallet && wallet.isConnected() && funds > needed + const enoughFundsOnMarina = funds > needed if (LightningEnabledTasks[Tasks.Borrow]) { const outOfBounds = swapDepositAmountOutOfBounds(needed) setEnoughFunds(enoughFundsOnMarina || !outOfBounds) } else { setEnoughFunds(enoughFundsOnMarina) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [ + assets, + balances, + contract.collateral.quantity, + contract.collateral.ticker, + ]) const handleClick = () => { setNewContract(contract) @@ -59,8 +64,7 @@ const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [contract.synthetic.quantity]) - const enabled = - wallet?.isConnected() && + const enabled = () => enoughFunds && collateral.quantity > 0 && collateral.value > 0 && @@ -76,7 +80,7 @@ const BorrowButton = ({ contract, minRatio, ratio }: BorrowButtonProps) => {
{contract.txid && ( - + )}
diff --git a/components/dropdown/index.tsx b/components/dropdown/index.tsx index 1e135f84..78f4e6fc 100644 --- a/components/dropdown/index.tsx +++ b/components/dropdown/index.tsx @@ -1,10 +1,13 @@ import classNames from 'classnames' +import Image from 'next/image' import React from 'react' export type DropDownProps = { title: string options: { + isActive: boolean value: string + icon: string onClick: () => Promise }[] } @@ -16,7 +19,7 @@ const DropDown: React.FC = ({ title, options }) => {
diff --git a/components/investments/balance.tsx b/components/investments/balance.tsx index ebc718aa..add5700e 100644 --- a/components/investments/balance.tsx +++ b/components/investments/balance.tsx @@ -6,7 +6,8 @@ import BalanceInFiat from 'components/balance/fiat' const TotalBalance = () => { const { wallet } = useContext(WalletContext) - if (!wallet?._isConnected()) return

🔌 Connect your wallet to view your balance

+ if (!wallet?.isConnected()) + return

🔌 Connect your wallet to view your balance

return (
diff --git a/components/investments/index.tsx b/components/investments/index.tsx index 3890ca89..b8bda1d8 100644 --- a/components/investments/index.tsx +++ b/components/investments/index.tsx @@ -8,17 +8,18 @@ import { WalletContext } from 'components/providers/wallet' import { ContractsContext } from 'components/providers/contracts' const Investments = () => { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { loading } = useContext(ContractsContext) const [investments, setInvestments] = useState() useEffect(() => { - if (network) { - fetchInvestments(network).then((data) => { - setInvestments(data) - }) + if (wallet) { + wallet + .getNetwork() + .then(fetchInvestments) + .then((data) => setInvestments(data)) } - }, [network]) + }, [wallet]) if (loading) return if (!investments) return Error fetching investments diff --git a/components/investments/list.tsx b/components/investments/list.tsx index 2d4b40c4..2b5abf00 100644 --- a/components/investments/list.tsx +++ b/components/investments/list.tsx @@ -8,17 +8,15 @@ import { WalletContext } from 'components/providers/wallet' import { ContractsContext } from 'components/providers/contracts' const InvestmentsList = () => { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { loading } = useContext(ContractsContext) const [investments, setInvestments] = useState() useEffect(() => { - if (network) { - fetchInvestments(network).then((data) => { - setInvestments(data) - }) + if (wallet) { + wallet.getNetwork().then(fetchInvestments).then(setInvestments) } - }, [network]) + }, [wallet]) if (loading) return if (!investments) return Error getting investments diff --git a/components/links/explorer.tsx b/components/links/explorer.tsx index c3ccf9de..5eeef8af 100644 --- a/components/links/explorer.tsx +++ b/components/links/explorer.tsx @@ -1,14 +1,10 @@ -import { WalletContext } from 'components/providers/wallet' -import { useContext } from 'react' - interface ExplorerLinkProps { extraClass?: string txid: string + network: string } -const ExplorerLink = ({ extraClass, txid }: ExplorerLinkProps) => { - const { network } = useContext(WalletContext) - +const ExplorerLink = ({ extraClass, txid, network }: ExplorerLinkProps) => { const href = network === 'testnet' ? `https://blockstream.info/liquidtestnet/tx/${txid}` diff --git a/components/modals/account.tsx b/components/modals/account.tsx index 2e60156b..383c75f0 100644 --- a/components/modals/account.tsx +++ b/components/modals/account.tsx @@ -1,6 +1,6 @@ import Spinner from 'components/spinner' import Modal, { ModalIds } from './modal' -import { marinaFujiAccountID } from 'lib/constants' +import { MarinaWallet } from 'lib/marina' const AccountModal = () => { return ( @@ -8,7 +8,8 @@ const AccountModal = () => {

Waiting for confirmation...

- Creating a Marina account named {marinaFujiAccountID} + Creating a Marina account named{' '} + {MarinaWallet.FujiAccountID}

Please accept and unlock on Marina

diff --git a/components/multiply/deposit.tsx b/components/multiply/deposit.tsx index 235cbfbf..7576252b 100644 --- a/components/multiply/deposit.tsx +++ b/components/multiply/deposit.tsx @@ -23,7 +23,7 @@ const MultiplyDeposit = ({ setChannel, setDeposit, }: MultiplyDepositProps) => { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { weblnProviderName } = useContext(WeblnContext) const [data, setData] = useState('') @@ -45,7 +45,7 @@ const MultiplyDeposit = ({ const handleMarina = () => {} // TODO const handleAlby = - weblnProviderName === 'Alby' && network === 'liquid' + weblnProviderName === 'Alby' ? async () => { setUseWebln(true) await handleLightning() diff --git a/components/providers/config.tsx b/components/providers/config.tsx index ce77e8f5..40a23fcc 100644 --- a/components/providers/config.tsx +++ b/components/providers/config.tsx @@ -39,7 +39,7 @@ export const ConfigContext = createContext({ }) export const ConfigProvider = ({ children }: { children: ReactNode }) => { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const [artifact, setArtifact] = useState(emptyArtifact) const [config, setConfig] = useState(emptyConfig) @@ -47,11 +47,13 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const reloadConfig = async () => { // return if network not defined - if (!network) { + if (!wallet) { setLoading(false) return } + const network = await wallet.getNetwork() + // fetch config from factory const config: ConfigResponse = await fetchConfig(network) if (!config) return @@ -90,9 +92,9 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { }, []) useEffect(() => { - if (network) reloadConfig() + if (wallet) reloadConfig() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [network]) + }, [wallet]) return ( diff --git a/components/providers/contracts.tsx b/components/providers/contracts.tsx index b8f26989..3564e8f1 100644 --- a/components/providers/contracts.tsx +++ b/components/providers/contracts.tsx @@ -25,11 +25,12 @@ import { WalletContext } from './wallet' import { isIonioScriptDetails, NetworkString, Utxo } from 'marina-provider' import { getActivities } from 'lib/activities' import { getFuncNameFromScriptHexOfLeaf } from 'lib/covenant' -import { getContractsFromMarina, getFujiCoins } from 'lib/marina' -import { marinaFujiAccountID } from 'lib/constants' +import { coinToContract } from 'lib/contracts' import { hex64LEToNumber } from 'lib/utils' -import { address } from 'liquidjs-lib' +import { address, networks } from 'liquidjs-lib' import { ConfigContext } from './config' +import { ChainSource, WsElectrumChainSource } from 'lib/chainsource.port' +import { fUSDAssetId } from 'lib/constants' interface ContractsContextProps { activities: Activity[] @@ -62,8 +63,7 @@ interface ContractsProviderProps { } export const ContractsProvider = ({ children }: ContractsProviderProps) => { - const { chainSource, connected, marina, network, xPubKey } = - useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { artifact, config, reloadConfig } = useContext(ConfigContext) const [activities, setActivities] = useState([]) @@ -91,10 +91,13 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { // update state (contracts, activities) with last changes on storage // setLoading(false) is there only to remove spinner on first render const reloadContracts = async () => { - if (connected && network) { + if (wallet && wallet.isConnected()) { await checkContractsStatus() - setContracts(await getContracts(assets, network)) - setActivities(await getActivities()) + const network = await wallet.getNetwork() + setContracts( + await getContracts(wallet.getMainAccountXPubKey(), assets, network), + ) + setActivities(await getActivities(wallet)) setLoading(false) } } @@ -106,8 +109,9 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { // mempool => hist.length == 1 && hist[0].height == 0 // confirm => hist.length > 0 && hist[0].height != 0 // spent => hist.length == 2 - const notConfirmed = async (contract: Contract) => { - if (!network) return + const notConfirmed = async (contract: Contract, chainSource: ChainSource) => { + if (!wallet) return + const network = await wallet.getNetwork() const [hist] = await chainSource.fetchHistories([ address.toOutputScript( await getContractCovenantAddress(artifact, contract, network), @@ -122,17 +126,25 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { // - check for creation tx (to confirm) // - check if unspend (for status) const checkContractsStatus = async () => { - if (!network) return + if (!wallet) return // function to check if contract has fuji coin - const fujiCoins = await getFujiCoins() + const fujiCoins = await wallet.getCoins() const hasCoin = (txid = '') => fujiCoins.some((coin) => coin.txid === txid) + const network = await wallet.getNetwork() + + // open a websocket connection to explorer + const chainSource = new WsElectrumChainSource(network) + // iterate through contracts in storage - for (const contract of getMyContractsFromStorage(network, xPubKey)) { + for (const contract of getMyContractsFromStorage( + network, + wallet.getMainAccountXPubKey(), + )) { if (!contract.txid) continue if (!contract.confirmed) { // if funding tx is not confirmed, we can skip this contract - if (await notConfirmed(contract)) continue + if (await notConfirmed(contract, chainSource)) continue markContractConfirmed(contract) } // if contract is redeemed, topup or liquidated @@ -185,24 +197,48 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { } } } + + await chainSource.close().catch(() => {}) // ignore errors from close } - // Marina could know about contracts that local storage doesn't + // Wallet could know about contracts that local storage doesn't // This could happen if the user is using more than one device // In this case, we will add the unknown contracts into storage - const syncContractsWithMarina = async () => { - if (!xPubKey) return + const syncContractsWithWallet = async () => { + if (!wallet) return + const network = await wallet.getNetwork() + if (network === 'regtest') return + const storageContracts = getContractsFromStorage() - const marinaContracts = await getContractsFromMarina(assets) + const allCoins = await wallet.getCoins() + const collateralAssetID = networks[network].assetHash // always L-BTC + + const collateralLockedBySynthContract = allCoins.filter( + (coin) => + coin.blindingData && + coin.blindingData.asset === collateralAssetID && + coin.scriptDetails && + isIonioScriptDetails(coin.scriptDetails) && + coin.scriptDetails.artifact.contractName === 'SyntheticAsset', + ) // check if contract from marina is on storage - const notInStorage = (mc: Contract) => + const notInStorage = (coin: Utxo) => storageContracts.some( - (sc) => sc.txid === mc.txid && sc.vout === mc.vout, + (sc) => sc.txid === coin.txid && sc.vout === coin.vout, ) === false - for (const contract of marinaContracts) { - if (notInStorage(contract)) { + const collateral = assets.find((a) => a.id === collateralAssetID) + const synthetic = assets.find((a) => a.id === fUSDAssetId) + + const xPubKey = wallet.getMainAccountXPubKey() + + if (!collateral || !synthetic) return + + for (const coin of collateralLockedBySynthContract.filter(notInStorage)) { + try { + const contract = coinToContract(network, coin, synthetic, collateral) + if (!contract) continue // add xPubKey to contract contract.xPubKey = xPubKey // check creation date so that activity will match @@ -211,39 +247,37 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { ? hex64LEToNumber(setupTimestamp) : undefined createNewContract(contract, timestamp) + } catch (e) { + console.error(e) } } } // reload contracts on marina events: NEW_UTXO, SPENT_UTXO - const setMarinaListener = () => { + const setWalletListener = () => { // try to avoid first burst of events sent by marina (on reload) const okToReload = (accountID: string) => - accountID === marinaFujiAccountID && Date.now() - lastReload.current > 30000 // add event listeners - if (connected && marina && xPubKey) { - const listenerFunction = async ({ - data: utxo, - }: { - utxo: Utxo - data: any - }) => { + if (wallet && wallet.isConnected()) { + const listenerFunction = async (utxo: Utxo) => { if ( !utxo || !utxo.scriptDetails || - !isIonioScriptDetails(utxo.scriptDetails) + !isIonioScriptDetails(utxo.scriptDetails) || + (isIonioScriptDetails(utxo.scriptDetails) && + utxo.scriptDetails.artifact.contractName !== 'SyntheticAsset') ) return if (okToReload(utxo.scriptDetails.accountName)) reloadAndMarkLastReload() } - const idSpentUtxo = marina.on('SPENT_UTXO', listenerFunction) - const idNewUtxo = marina.on('NEW_UTXO', listenerFunction) + const offNewUtxo = wallet.onNewUtxo(listenerFunction) + const offSpentUtxo = wallet.onSpentUtxo(listenerFunction) return () => { - marina.off(idSpentUtxo) - marina.off(idNewUtxo) + offNewUtxo() + offSpentUtxo() } } return () => {} @@ -253,19 +287,20 @@ export const ContractsProvider = ({ children }: ContractsProviderProps) => { useEffect(() => { async function runOnAssetsChange() { - if (network && assets.length) { + if (wallet && assets.length) { + const network = await wallet.getNetwork() if (!firstRender.current.includes(network)) { - await syncContractsWithMarina() + await syncContractsWithWallet() firstRender.current.push(network) } await reloadContracts() setLoading(false) - return setMarinaListener() // return the close listener function + return setWalletListener() // return the close listener function } } runOnAssetsChange() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [assets, connected]) + }, [assets, wallet]) return ( } @@ -51,7 +44,6 @@ export const WalletContext = createContext({ wallets: [], wallet: undefined, selectWallet: () => Promise.resolve(), - network: defaultNetwork, }) interface WalletProviderProps { @@ -61,50 +53,24 @@ interface WalletProviderProps { export const WalletProvider = ({ children }: WalletProviderProps) => { const [wallets, setWallets] = useState([]) const [wallet, setWallet] = useState(undefined) - const [closeNetworkListener, setCloseNetworkLst] = useState<() => void>() - const [chainSource, setChainSource] = useState<{ - network: string - src: ChainSource - }>() const selectWallet = useCallback( async (type: WalletType) => { const wallet = wallets.find((wallet) => wallet.type === type) if (!wallet) throw new Error(`Wallet ${type} not found`) setWallet(wallet) - // close previous listeners - closeNetworkListener?.() - chainSource?.src.close() // close chain source - - const closeOnNetworkChange = wallet.onNetworkChange((network) => { - if (network !== chainSource?.network) { - closeNetworkListener?.() - chainSource?.src.close().catch(console.error) - const src = new WsElectrumChainSource(network) - setChainSource({ - network, - src, - }) - } - }) - setCloseNetworkLst(() => closeOnNetworkChange) }, - [wallets, chainSource, closeNetworkListener], + [wallets], ) useEffect(() => { - detectWallets() - .then(setWallets) - .then(() => selectWallet(WalletType.Marina)) // select marina by default - .catch(console.error) + detectWallets().then(setWallets).catch(console.error) }, [selectWallet]) return ( { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const [weblnCanEnable, setWeblnCanEnable] = useState(true) const [weblnIsEnabled, setweblnIsEnabled] = useState(false) @@ -61,7 +61,7 @@ export const WeblnProvider = ({ children }: WeblnProviderProps) => { // if webln support detected, asks user to enable it useEffect(() => { - if (window.webln && network === 'liquid') { + if (window.webln && wallet) { setWeblnProvider(window.webln) if (window.webln.enabled) setweblnIsEnabled(true) else if (!alreadyAsk.current) { @@ -69,7 +69,7 @@ export const WeblnProvider = ({ children }: WeblnProviderProps) => { alreadyAsk.current = true } } - }, [network]) + }, [wallet]) return ( { />

Success

- +  

diff --git a/components/stocks/list.tsx b/components/stocks/list.tsx index 393129e7..61b08a6f 100644 --- a/components/stocks/list.tsx +++ b/components/stocks/list.tsx @@ -8,17 +8,15 @@ import { WalletContext } from 'components/providers/wallet' import { ContractsContext } from 'components/providers/contracts' const StocksList = () => { - const { network } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const { loading } = useContext(ContractsContext) const [stocks, setStocks] = useState() useEffect(() => { - if (network) { - fetchStocks(network).then((data) => { - setStocks(data) - }) + if (wallet) { + wallet.getNetwork().then(fetchStocks).then(setStocks) } - }, [network]) + }, [wallet]) if (loading) return if (!stocks) return Error getting stocks diff --git a/components/topup/button.tsx b/components/topup/button.tsx index 125860c5..c554d613 100644 --- a/components/topup/button.tsx +++ b/components/topup/button.tsx @@ -11,10 +11,10 @@ interface TopupButtonProps { } const TopupButton = ({ minRatio, oracles, ratio, topup }: TopupButtonProps) => { - const { connected } = useContext(WalletContext) + const { wallet } = useContext(WalletContext) const enabled = - connected && + wallet?.isConnected && topup > minDustLimit + feeAmount && ratio > minRatio && oracles.length > 0 diff --git a/lib/activities.ts b/lib/activities.ts index 0377c3f4..0019fcc8 100644 --- a/lib/activities.ts +++ b/lib/activities.ts @@ -1,4 +1,3 @@ -import { getNetwork, getMainAccountXPubKey } from './marina' import { prettyAsset } from './pretty' import { addActivityToStorage, @@ -6,6 +5,7 @@ import { saveActivitiesToStorage, } from './storage' import { Activity, ActivityType, Contract } from './types' +import { Wallet } from './wallet' // add activity type for given contract to storage // removes all previous activities with this type to garantee uniqueness @@ -37,9 +37,9 @@ export function addActivity( } // get all activities on storage for this network -export async function getActivities(): Promise { - const network = await getNetwork() - const xPubKey = await getMainAccountXPubKey() +export async function getActivities(wallet: Wallet): Promise { + const network = await wallet.getNetwork() + const xPubKey = wallet.getMainAccountXPubKey() return getActivitiesFromStorage().filter( (activity: Activity) => activity.network === network && activity.contract.xPubKey === xPubKey, diff --git a/lib/chainsource.port.ts b/lib/chainsource.port.ts index 4bda11a9..216cda80 100644 --- a/lib/chainsource.port.ts +++ b/lib/chainsource.port.ts @@ -31,6 +31,7 @@ export type ChainSource = { fetchBlockHeader(height: number): Promise fetchTransactions(txids: string[]): Promise<{ txID: string; hex: string }[]> listUnspents(address: string): Promise + broadcastTransaction(txHex: string): Promise close(): Promise } @@ -46,6 +47,7 @@ const electrumURL = (network: NetworkString): string => { } } +const BroadcastTransaction = 'blockchain.transaction.broadcast' // returns txid const GetTransactionMethod = 'blockchain.transaction.get' const GetHistoryMethod = 'blockchain.scripthash.get_history' const GetBlockHeaderMethod = 'blockchain.block.header' @@ -135,6 +137,10 @@ export class WsElectrumChainSource implements ChainSource { }) } + async broadcastTransaction(hex: string): Promise { + return this.ws.request(BroadcastTransaction, hex) + } + async close() { try { await this.ws.close('close') diff --git a/lib/contracts.ts b/lib/contracts.ts index c651cc7e..c9afd4f2 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -236,14 +236,9 @@ export async function getContract( ): Promise { const coins = await wallet.getCoins() const network = await wallet.getNetwork() - const coin = coins.find((c) => c.txid === txid) - if (!coin) return - return coinToContract( - network, - coin, - synthetic, - collateral, - ) + const coin = coins.find((c) => c.txid === txid) + if (!coin) return + return coinToContract(network, coin, synthetic, collateral) } // add contract to storage and create activity diff --git a/lib/covenant.ts b/lib/covenant.ts index 4d60efbe..6b253247 100644 --- a/lib/covenant.ts +++ b/lib/covenant.ts @@ -30,22 +30,13 @@ import { TopupContractArgs, topupRequest, } from './fetch' -import { - createFujiAccount, - fujiAccountMissing, - getFujiCoins, - getMainAccountCoins, - getMarinaProvider, - getNextAddress, - getNextChangeAddress, - getNextCovenantAddress, - getPublicKey, -} from './marina' +import { createFujiAccount, fujiAccountMissing } from './marina' import * as ecc from 'tiny-secp256k1' import { Artifact, Contract as IonioContract } from '@ionio-lang/ionio' import { selectCoins } from './selection' import { Network } from 'liquidjs-lib/src/networks' import { getFactoryUrl } from './api' +import { Wallet } from './wallet' const getNetwork = (str?: NetworkString): Network => { return str ? (networks as Record)[str] : networks.liquid @@ -82,22 +73,19 @@ export async function getIonioInstance( } async function getCovenantOutput( + wallet: Wallet, artifact: Artifact, contract: Contract, oracle: Oracle, ): Promise<{ contractParams: ContractParams covenantOutput: UpdaterOutput - covenantAddress: Address + confidentialAddress: string }> { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') - // set contract params const timestamp = Date.now() const treasuryPk = Buffer.from(treasuryPublicKey, 'hex') - const contractParams: Omit = { + const parametersWithoutKey: Omit = { borrowAsset: contract.synthetic.id, borrowAmount: contract.synthetic.quantity, oraclePublicKey: `0x${oracle.pubkey}`, @@ -108,12 +96,12 @@ async function getCovenantOutput( assetPair, } - const covenantAddress = await getNextCovenantAddress(artifact, contractParams) + const { confidentialAddress, contractParams } = + await wallet.getNextCovenantAddress(artifact, parametersWithoutKey) // set covenant output - const { scriptPubKey } = address.fromConfidential( - covenantAddress.confidentialAddress, - ) + const { scriptPubKey } = address.fromConfidential(confidentialAddress) + const covenantOutput: UpdaterOutput = { script: scriptPubKey, amount: contract.collateral.quantity, @@ -121,14 +109,9 @@ async function getCovenantOutput( } return { - contractParams: { - ...contractParams, - borrowerPublicKey: `0x${(await getPublicKey(covenantAddress)) - .subarray(1) - .toString('hex')}`, - }, + contractParams, covenantOutput, - covenantAddress, + confidentialAddress, } } @@ -144,19 +127,13 @@ export interface PreparedBorrowTx { } export async function prepareBorrowTxWithClaimTx( + wallet: Wallet, artifact: Artifact, contract: Contract, utxos: Utxo[], redeemScript: string, // must be associated with the first utxo oracle: Oracle, ): Promise { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') - - // check for marina account, create if doesn't exists - if (await fujiAccountMissing(marina)) await createFujiAccount(marina) - // validate contract const { collateral, synthetic } = contract if (!collateral.quantity) @@ -173,6 +150,7 @@ export async function prepareBorrowTxWithClaimTx( // get covenant const { contractParams, covenantOutput } = await getCovenantOutput( + wallet, artifact, contract, oracle, @@ -192,7 +170,7 @@ export async function prepareBorrowTxWithClaimTx( .addOutputs([covenantOutput]) return { - borrowerAddress: await getNextAddress(), + borrowerAddress: await wallet.getNextAddress(), collateralUtxos: utxos, contractParams, pset: updater.pset, @@ -202,17 +180,11 @@ export async function prepareBorrowTxWithClaimTx( } export async function prepareBorrowTx( + wallet: Wallet, artifact: Artifact, contract: Contract, oracle: Oracle, ): Promise { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') - - // check for marina account, create if doesn't exists - if (await fujiAccountMissing(marina)) await createFujiAccount(marina) - // validate contract const { collateral, synthetic } = contract if (!collateral.quantity) @@ -222,13 +194,15 @@ export async function prepareBorrowTx( if (!contract.priceLevel) throw new Error('Invalid contract: no contract priceLevel') - const utxos = await getMainAccountCoins() + const utxos = await wallet.getCoins() + // validate we have necessary utxo - const collateralUtxos = selectCoins( + const { selection: collateralUtxos, change: changeAmount } = selectCoins( utxos, collateral.id, collateral.quantity + feeAmount, ) + if (collateralUtxos.length === 0) throw new Error('Not enough collateral funds') @@ -238,6 +212,7 @@ export async function prepareBorrowTx( // get covenant params const { contractParams, covenantOutput } = await getCovenantOutput( + wallet, artifact, contract, oracle, @@ -255,15 +230,9 @@ export async function prepareBorrowTx( ) .addOutputs([covenantOutput]) - // add change output - let changeAddress - const collateralUtxosAmount = collateralUtxos.reduce( - (value, utxo) => value + (utxo.blindingData?.value || 0), - 0, - ) - const changeAmount = collateralUtxosAmount - collateral.quantity - feeAmount + let changeAddress = undefined if (changeAmount > 0) { - changeAddress = await getNextChangeAddress() + changeAddress = await wallet.getNextChangeAddress() const { scriptPubKey, blindingKey } = address.fromConfidential( changeAddress.confidentialAddress, ) @@ -279,7 +248,7 @@ export async function prepareBorrowTx( } return { - borrowerAddress: await getNextAddress(), + borrowerAddress: await wallet.getNextAddress(), changeAddress, collateralUtxos, contractParams, @@ -370,15 +339,12 @@ export async function proposeBorrowContract( // redeem export async function prepareRedeemTx( + wallet: Wallet, artifact: Artifact, contract: Contract, network: NetworkString, swapAddress?: string, ) { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') - // validate contract const { collateral, synthetic } = contract if (!collateral.quantity) @@ -390,14 +356,15 @@ export async function prepareRedeemTx( if (collateral.quantity < feeAmount + minDustLimit) throw new Error('Invalid contract: collateral amount too low') - const address = swapAddress || (await getNextAddress()).confidentialAddress + const address = + swapAddress || (await wallet.getNextAddress()).confidentialAddress // get ionio instance let ionioInstance = await getIonioInstance(artifact, contract, network) // find coin for this contract - const collateralCoins = await getFujiCoins() - const coinToRedeem = collateralCoins.find( + const coins = await wallet.getCoins() + const coinToRedeem = coins.find( (c) => c.txid === contract.txid && c.vout === contract.vout, ) if (!coinToRedeem) @@ -407,21 +374,14 @@ export async function prepareRedeemTx( ) // validate we have sufficient synthetic funds - const utxos = await getMainAccountCoins() - const syntheticUtxos = selectCoins(utxos, synthetic.id, synthetic.quantity) + const { selection: syntheticUtxos, change: syntheticChangeAmount } = + selectCoins(coins, synthetic.id, synthetic.quantity) if (syntheticUtxos.length === 0) throw new Error('Not enough fuji funds') - // calculate synthetic change amount - const syntheticUtxosAmount = syntheticUtxos.reduce( - (value, utxo) => value + (utxo.blindingData?.value || 0), - 0, - ) - const syntheticChangeAmount = syntheticUtxosAmount - synthetic.quantity - // marina signer for ionio redeem function const marinaSigner = { - signTransaction: async (base64: string) => { - return await marina.signTransaction(base64) + signTransaction: (base64: string) => { + return wallet.signPset(base64) }, } @@ -478,7 +438,7 @@ export async function prepareRedeemTx( // add synthetic change if any if (syntheticChangeAmount > 0) { - const syntheticChangeAddress = await getNextChangeAddress() + const syntheticChangeAddress = await wallet.getNextChangeAddress() tx.withRecipient( syntheticChangeAddress.confidentialAddress, syntheticChangeAmount, @@ -506,6 +466,7 @@ export interface PreparedTopupTx { } export async function prepareTopupTx( + wallet: Wallet, artifact: Artifact, newContract: Contract, oldContract: Contract, @@ -513,13 +474,6 @@ export async function prepareTopupTx( collateralUtxos: (Utxo & { redeemScript?: string })[], oracle: Oracle, ): Promise { - // check for marina - const marina = await getMarinaProvider() - if (!marina) throw new Error('Please install Marina') - - // check for marina account, create if doesn't exists - if (await fujiAccountMissing(marina)) await createFujiAccount(marina) - // validate contracts if (!newContract.collateral.quantity) throw new Error('Invalid new contract: no collateral quantity') @@ -539,32 +493,32 @@ export async function prepareTopupTx( const topupAmount = newContract.collateral.quantity - oldContract.collateral.quantity + const coins = await wallet.getCoins() + const coinToTopup = coins.find( + (c) => c.txid === oldContract.txid && c.vout === oldContract.vout, + ) + if (!coinToTopup) + throw new Error( + 'Contract cannot be found in the connected wallet. ' + + 'Wait for confirmations or try to reload the wallet and try again.', + ) + // validate we have sufficient synthetic funds to burn - const syntheticUtxos = selectCoins( - await getMainAccountCoins(), + const { selection: syntheticUtxos, change } = selectCoins( + coins, burnAsset, burnAmount, ) if (syntheticUtxos.length === 0) throw new Error('Not enough fuji funds') // get new covenant params - const { contractParams, covenantAddress } = await getCovenantOutput( + const { contractParams, confidentialAddress } = await getCovenantOutput( + wallet, artifact, newContract, oracle, ) - // find coin for this contract - const coins = await getFujiCoins() - const coinToTopup = coins.find( - (c) => c.txid === oldContract.txid && c.vout === oldContract.vout, - ) - if (!coinToTopup) - throw new Error( - 'Contract cannot be found in the connected wallet. ' + - 'Wait for confirmations or try to reload the wallet and try again.', - ) - const { txid, vout, witnessUtxo, blindingData } = coinToTopup if (!witnessUtxo) throw new Error('Invalid witnessUtxo') @@ -592,9 +546,8 @@ export async function prepareTopupTx( // signatures needed for topup const marinaSigner = { - signTransaction: async (base64: string) => { - const signed = await marina.signTransaction(base64) - return signed + signTransaction: (base64: string) => { + return wallet.signPset(base64) }, } const skipSignature = { @@ -668,8 +621,7 @@ export async function prepareTopupTx( // new covenant output // the covenant must be always unconf! tx.withRecipient( - address.fromConfidential(covenantAddress.confidentialAddress!) - .unconfidentialAddress, + address.fromConfidential(confidentialAddress).unconfidentialAddress, newContract.collateral.quantity, newContract.collateral.id, ) @@ -682,7 +634,7 @@ export async function prepareTopupTx( ) const collateralChangeAmount = collateralUtxosAmount - topupAmount - feeAmount if (collateralChangeAmount > 0) { - collateralChangeAddress = await getNextChangeAddress() + collateralChangeAddress = await wallet.getNextChangeAddress() tx.withRecipient( collateralChangeAddress.confidentialAddress, collateralChangeAmount, @@ -699,7 +651,7 @@ export async function prepareTopupTx( ) const syntheticChangeAmount = syntheticUtxosAmount - burnAmount if (syntheticChangeAmount > 0) { - syntheticChangeAddress = await getNextChangeAddress() + syntheticChangeAddress = await wallet.getNextChangeAddress() tx.withRecipient( syntheticChangeAddress.confidentialAddress, syntheticChangeAmount, @@ -709,7 +661,7 @@ export async function prepareTopupTx( } return { - borrowerAddress: await getNextAddress(), + borrowerAddress: await wallet.getNextAddress(), coinToTopup, contractParams, pset: tx.pset, diff --git a/lib/hooks.ts b/lib/hooks.ts index 9d4a8651..e0330f43 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -10,8 +10,10 @@ export const useSelectBalances = (wallet?: Wallet) => { setBalances([]) return } + const getBalances = async () => { const balances = await wallet.getBalances() + console.log('balances', balances) setBalances(balances) } diff --git a/lib/marina.ts b/lib/marina.ts index c0c8044c..c9f0488e 100644 --- a/lib/marina.ts +++ b/lib/marina.ts @@ -1,3 +1,4 @@ +import * as ecc from 'tiny-secp256k1' import { Asset, ContractParams } from './types' import { detectProvider, @@ -9,11 +10,13 @@ import { AccountType, AccountID, Address, + isIonioScriptDetails, } from 'marina-provider' -import { Artifact } from '@ionio-lang/ionio' +import { Artifact, Contract as IonioContract } from '@ionio-lang/ionio' import { Wallet, WalletType } from './wallet' import { closeModal, openModal } from './utils' import { ModalIds } from 'components/modals/modal' +import { BIP32Factory } from 'bip32' export class MarinaWallet implements Wallet { static FujiAccountID = 'fuji' // slip13(fuji) @@ -32,8 +35,10 @@ export class MarinaWallet implements Wallet { if (!marina) return undefined const instance = new MarinaWallet(marina) instance._isConnected = await instance.marina.isEnabled() - if (!instance._isConnected) await instance.connect() - const infos = await instance.marina.getAccountInfo(MarinaWallet.MainAccountID) + if (!instance.isConnected()) await instance.connect() + const infos = await instance.marina.getAccountInfo( + MarinaWallet.MainAccountID, + ) instance._xPub = infos.masterXPub return instance } @@ -64,7 +69,7 @@ export class MarinaWallet implements Wallet { } async getBalances(): Promise { - if (!this._isConnected) return [] + if (!this.isConnected) return [] const network = await this.getNetwork() const mainAccountIDs = await getMainAccountIDs(network) return this.marina.getBalances(mainAccountIDs) @@ -88,14 +93,33 @@ export class MarinaWallet implements Wallet { async getNextAddress(): Promise

{ const network = await this.getNetwork() - const account = network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID + const account = + network === 'liquid' + ? MarinaWallet.MainAccountID + : MarinaWallet.TestnetMainAccountID await this.marina.useAccount(account) return this.marina.getNextAddress() } + async getNewPublicKey(): Promise { + const newAddress = await this.getNextAddress() + if (!newAddress.derivationPath) throw new Error('Invalid derivation path') + const accountInfos = await this.marina.getAccountInfo( + newAddress.accountName, + ) + + return BIP32Factory(ecc) + .fromBase58(accountInfos.masterXPub) // derive from fuji account + .derivePath(newAddress.derivationPath.replace('m/', '')) // use the new derivation path + .publicKey.toString('hex') + } + async getNextChangeAddress(): Promise
{ const network = await this.getNetwork() - const account = network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID + const account = + network === 'liquid' + ? MarinaWallet.MainAccountID + : MarinaWallet.TestnetMainAccountID await this.marina.useAccount(account) return this.marina.getNextChangeAddress() } @@ -103,27 +127,61 @@ export class MarinaWallet implements Wallet { async getNextCovenantAddress( artifact: Artifact, params: Omit, - ): Promise
{ - await this.marina.useAccount(MarinaWallet.FujiAccountID) - const covenantAddress = await this.marina.getNextAddress({ - artifact, - args: params, - }) - return covenantAddress + ): Promise<{ + contract: IonioContract + confidentialAddress: string + contractParams: ContractParams + }> { + await this.marina.useAccount(MarinaWallet.FujiAccountID) + const covenantAddress = await this.marina.getNextAddress({ + artifact, + args: params, + }) + + if (!covenantAddress.contract) throw new Error('Contract not found') + if (!isIonioScriptDetails(covenantAddress)) + throw new Error('Invalid contract') + if (!covenantAddress.derivationPath) + throw new Error('Invalid derivation path') + + // recompute the borrowerPublicKey + const accountInfos = await this.marina.getAccountInfo( + MarinaWallet.FujiAccountID, + ) + const key = BIP32Factory(ecc) + .fromBase58(accountInfos.masterXPub) // derive from fuji account + .derivePath(covenantAddress.derivationPath.replace('m/', '')) // use the new derivation path + .publicKey.subarray(1) // remove the prefix (0x02 or 0x03) + .toString('hex') + + return { + contract: covenantAddress.contract, + confidentialAddress: covenantAddress.confidentialAddress, + contractParams: { + ...params, + borrowerPublicKey: `0x${key}`, + }, + } } onSpentUtxo(callback: (utxo: Utxo) => void): () => void { - const id = this.marina.on('SPENT_UTXO', callback) + const id = this.marina.on('SPENT_UTXO', ({ data }: { data: Utxo }) => + callback(data), + ) return () => this.marina.off(id) } onNewUtxo(callback: (utxo: Utxo) => void): () => void { - const id = this.marina.on('NEW_UTXO', callback) + const id = this.marina.on('NEW_UTXO', ({ data }: { data: Utxo }) => + callback(data), + ) return () => this.marina.off(id) } onNetworkChange(callback: (network: NetworkString) => void): () => void { - const id = this.marina.on('NETWORK', callback) + const id = this.marina.on('NETWORK', ({ data }: { data: NetworkString }) => + callback(data), + ) return () => this.marina.off(id) } } @@ -151,6 +209,8 @@ async function getMainAccountIDs( ): Promise { const mainAccounts = withLegacy ? [MarinaWallet.LegacyMainAccountID] : [] return mainAccounts.concat( - network === 'liquid' ? MarinaWallet.MainAccountID : MarinaWallet.TestnetMainAccountID, + network === 'liquid' + ? MarinaWallet.MainAccountID + : MarinaWallet.TestnetMainAccountID, ) } diff --git a/lib/selection.ts b/lib/selection.ts index 8be9a552..af6d1de9 100644 --- a/lib/selection.ts +++ b/lib/selection.ts @@ -104,7 +104,7 @@ export function selectCoins( utxos: Utxo[], asset: string, minAmount: number, -): Utxo[] { +): { selection: Utxo[]; change: number } { // sort utxos in descending order of value will decrease number of inputs // (and fees) but will increase utxo fragmentation const _utxos = utxos @@ -112,8 +112,21 @@ export function selectCoins( .sort((a, b) => utxoValue(b) - utxoValue(a)) // try to find a combination with exact value (aka no change) first - return ( + const selection = branchAndBoundStrategy(_utxos, minAmount) ?? accumulativeStrategy(_utxos, minAmount) + + // if no coins found, throw error + if (!selection || selection.length === 0) { + throw new Error('Not enough funds') + } + + // calculate change + const totalAmount = selection.reduce( + (value, utxo) => value + (utxo.blindingData?.value || 0), + 0, ) + const change = totalAmount - minAmount + + return { selection, change } } diff --git a/lib/utils.ts b/lib/utils.ts index cc561cc2..f1dac4bc 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -95,12 +95,10 @@ export const retry = ( setData: (arg0: string) => void, setResult: (arg0: string) => void, handler = () => {}, - updateBalances = () => {}, ) => { return () => { setData('') setResult('') - updateBalances() handler() } } diff --git a/lib/wallet.ts b/lib/wallet.ts index 857e72f0..38b14884 100644 --- a/lib/wallet.ts +++ b/lib/wallet.ts @@ -1,6 +1,12 @@ -import { Artifact } from '@ionio-lang/ionio'; -import type { Address, Balance, NetworkString, Transaction, Utxo } from 'marina-provider' -import { ContractParams } from './types'; +import { Artifact, Contract as IonioContract } from '@ionio-lang/ionio' +import type { + Address, + Balance, + NetworkString, + Transaction, + Utxo, +} from 'marina-provider' +import { ContractParams } from './types' export enum WalletType { Marina = 'marina', @@ -8,28 +14,33 @@ export enum WalletType { } export interface Wallet { - type: WalletType; - isConnected(): boolean; - - connect(): Promise; - disconnect(): Promise; + type: WalletType + isConnected(): boolean - getMainAccountXPubKey(): string; - getBalances(): Promise; - getCoins(): Promise; - getTransactions(): Promise; - getNetwork(): Promise; + connect(): Promise + disconnect(): Promise - getNextAddress(): Promise
; - getNextChangeAddress(): Promise
; - getNextCovenantAddress( - artifact: Artifact, - params: Omit // wallet should "inject" the borrower public key parameter - ): Promise
; + getMainAccountXPubKey(): string + getBalances(): Promise + getCoins(): Promise // returns all coins + getTransactions(): Promise + getNetwork(): Promise - signPset(psetBase64: string): Promise; + getNextAddress(): Promise
+ getNextChangeAddress(): Promise
+ getNextCovenantAddress( + artifact: Artifact, + params: Omit, // wallet should "inject" the borrower public key parameter + ): Promise<{ + confidentialAddress: string + contract: IonioContract + contractParams: ContractParams + }> + getNewPublicKey(): Promise // this is used mostly for Boltz - onSpentUtxo(callback: (utxo: Utxo) => void): () => void; - onNewUtxo(callback: (utxo: Utxo) => void): () => void; - onNetworkChange(callback: (network: NetworkString) => void): () => void; + signPset(psetBase64: string): Promise + + onSpentUtxo(callback: (utxo: Utxo) => void): () => void + onNewUtxo(callback: (utxo: Utxo) => void): () => void + onNetworkChange(callback: (network: NetworkString) => void): () => void } diff --git a/pages/_app.tsx b/pages/_app.tsx index 2cd0f9e7..4a6d8d09 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -12,6 +12,11 @@ function MyApp({ Component, pageProps }: AppProps) { src="https://analytics.fuji.money/js/plausible.js" strategy="lazyOnload" /> +