Changeset View
Changeset View
Standalone View
Standalone View
cashtab/src/components/Agora/OrderBook/index.tsx
| Show All 31 Lines | |||||
| import { | import { | ||||
| nanoSatoshisToXec, | nanoSatoshisToXec, | ||||
| decimalizeTokenAmount, | decimalizeTokenAmount, | ||||
| toXec, | toXec, | ||||
| DUMMY_KEYPAIR, | DUMMY_KEYPAIR, | ||||
| toBigInt, | toBigInt, | ||||
| SlpDecimals, | SlpDecimals, | ||||
| undecimalizeTokenAmount, | undecimalizeTokenAmount, | ||||
| CashtabUtxo, | |||||
| } from 'wallet'; | } from 'wallet'; | ||||
| import { ignoreUnspendableUtxos } from 'transactions'; | |||||
| import { | import { | ||||
| toFormattedXec, | toFormattedXec, | ||||
| getFormattedFiatPrice, | getFormattedFiatPrice, | ||||
| decimalizedTokenQtyToLocaleFormat, | decimalizedTokenQtyToLocaleFormat, | ||||
| getAgoraSpotPriceXec, | getAgoraSpotPriceXec, | ||||
| getPercentDeltaOverSpot, | getPercentDeltaOverSpot, | ||||
| } from 'formatting'; | } from 'formatting'; | ||||
| import { | import { | ||||
| Show All 23 Lines | import { | ||||
| AgoraPreviewParagraph, | AgoraPreviewParagraph, | ||||
| AgoraPreviewTable, | AgoraPreviewTable, | ||||
| AgoraPreviewRow, | AgoraPreviewRow, | ||||
| AgoraPreviewLabel, | AgoraPreviewLabel, | ||||
| AgoraPreviewCol, | AgoraPreviewCol, | ||||
| } from 'components/Etokens/Token/styled'; | } from 'components/Etokens/Token/styled'; | ||||
| import PrimaryButton, { SecondaryButton } from 'components/Common/Buttons'; | import PrimaryButton, { SecondaryButton } from 'components/Common/Buttons'; | ||||
| import Modal from 'components/Common/Modal'; | import Modal from 'components/Common/Modal'; | ||||
| import { | import { toHex, shaRmd160, Address } from 'ecash-lib'; | ||||
| Script, | |||||
| P2PKHSignatory, | |||||
| ALL_BIP143, | |||||
| toHex, | |||||
| fromHex, | |||||
| shaRmd160, | |||||
| Address, | |||||
| } from 'ecash-lib'; | |||||
| import appConfig from 'config/app'; | import appConfig from 'config/app'; | ||||
| import { toast } from 'react-toastify'; | import { toast } from 'react-toastify'; | ||||
| import TokenIcon from 'components/Etokens/TokenIcon'; | import TokenIcon from 'components/Etokens/TokenIcon'; | ||||
| import { getAgoraPartialAcceptTokenQtyError } from 'validation'; | import { getAgoraPartialAcceptTokenQtyError } from 'validation'; | ||||
| import { Alert, Info, CopyTokenId } from 'components/Common/Atoms'; | import { Alert, Info, CopyTokenId } from 'components/Common/Atoms'; | ||||
| import { | import { AgoraOffer, AgoraPartial } from 'ecash-agora'; | ||||
| AgoraOffer, | |||||
| AgoraPartial, | |||||
| getAgoraPartialAcceptFuelInputs, | |||||
| getAgoraCancelFuelInputs, | |||||
| } from 'ecash-agora'; | |||||
| import { IsMintAddressIcon } from 'components/Common/CustomIcons'; | import { IsMintAddressIcon } from 'components/Common/CustomIcons'; | ||||
| /** | /** | ||||
| * Allow users to buy above spot (within reason) | * Allow users to buy above spot (within reason) | ||||
| * There are legitimate use cases for buying above spot | * There are legitimate use cases for buying above spot | ||||
| * - Perhaps someone has created an offer with a "low" price but a VERYHIGH min accept, so | * - Perhaps someone has created an offer with a "low" price but a VERYHIGH min accept, so | ||||
| * the actual buy cost is very high, blocking off other offers | * the actual buy cost is very high, blocking off other offers | ||||
| * - Perhaps there is an offer that has a higher price, but allows the user to purchase | * - Perhaps there is an offer that has a higher price, but allows the user to purchase | ||||
| ▲ Show 20 Lines • Show All 89 Lines • ▼ Show 20 Lines | const OrderBook: React.FC<OrderBookProps> = ({ | ||||
| noWebsocket = false, | noWebsocket = false, | ||||
| priceInFiat = false, | priceInFiat = false, | ||||
| }) => { | }) => { | ||||
| const ContextValue = useContext(WalletContext); | const ContextValue = useContext(WalletContext); | ||||
| if (!isWalletContextLoaded(ContextValue)) { | if (!isWalletContextLoaded(ContextValue)) { | ||||
| // Confirm we have all context required to load the page | // Confirm we have all context required to load the page | ||||
| return null; | return null; | ||||
| } | } | ||||
| const { fiatPrice, chronik, agora, cashtabState, chaintipBlockheight } = | const { fiatPrice, chronik, agora, cashtabState, ecashWallet } = | ||||
| ContextValue; | ContextValue; | ||||
| const { settings, cashtabCache, activeWallet } = cashtabState; | const { settings, cashtabCache, activeWallet } = cashtabState; | ||||
| if (typeof activeWallet === 'undefined') { | if (typeof activeWallet === 'undefined' || !ecashWallet) { | ||||
| // Note that, in the app, we will never render this component without an activeWallet | // Note that, in the app, we will never render this component without an activeWallet | ||||
| // Because the App component will only show OnBoarding in this case | // Because the App component will only show OnBoarding in this case | ||||
| // But because we directly test this component with context, we must handle this case | // But because we directly test this component with context, we must handle this case | ||||
| return null; | return null; | ||||
| } | } | ||||
| const wallet = activeWallet; | const wallet = activeWallet; | ||||
| const { balanceSats } = wallet.state; | const { balanceSats } = wallet.state; | ||||
| Show All 17 Lines | const OrderBook: React.FC<OrderBookProps> = ({ | ||||
| * - OrderBook websocket updates its active offers | * - OrderBook websocket updates its active offers | ||||
| */ | */ | ||||
| const [ws, setWs] = useState<null | WsEndpoint>(null); | const [ws, setWs] = useState<null | WsEndpoint>(null); | ||||
| const cancelOffer = async (agoraPartial: PartialOffer) => { | const cancelOffer = async (agoraPartial: PartialOffer) => { | ||||
| // Get user fee from settings | // Get user fee from settings | ||||
| const satsPerKb: bigint = BigInt(settings.satsPerKb); | const satsPerKb: bigint = BigInt(settings.satsPerKb); | ||||
| // Potential input utxos for this transaction | |||||
| // non-token utxos that are spendable | |||||
| const eligibleUtxos = ignoreUnspendableUtxos( | |||||
| wallet.state.nonSlpUtxos, | |||||
| chaintipBlockheight, | |||||
| ); | |||||
| // Get utxos to cover the cancel fee | |||||
| let fuelUtxos; | |||||
| try { | try { | ||||
| fuelUtxos = getAgoraCancelFuelInputs( | // Use ecash-agora's cancel() method which handles fuel inputs and broadcasting via ecash-wallet | ||||
| agoraPartial, | const broadcastResult = await agoraPartial.cancel({ | ||||
| eligibleUtxos, | wallet: ecashWallet, | ||||
| satsPerKb, | |||||
| ) as CashtabUtxo[]; | |||||
| } catch (err) { | |||||
| console.error( | |||||
| 'Error determining fuel inputs for offer cancel', | |||||
| err, | |||||
| ); | |||||
| return toast.error(`${err}`); | |||||
| } | |||||
| const fuelInputs = []; | |||||
| for (const fuelUtxo of fuelUtxos) { | |||||
| // Convert from Cashtab utxo to signed ecash-lib input | |||||
| fuelInputs.push({ | |||||
| input: { | |||||
| prevOut: { | |||||
| txid: fuelUtxo.outpoint.txid, | |||||
| outIdx: fuelUtxo.outpoint.outIdx, | |||||
| }, | |||||
| signData: { | |||||
| sats: fuelUtxo.sats, | |||||
| // Send the tokens back to the same address as the fuelUtxo | |||||
| outputScript: Script.p2pkh(fromHex(wallet.hash)), | |||||
| }, | |||||
| }, | |||||
| signatory: P2PKHSignatory( | |||||
| fromHex(wallet.sk), | |||||
| fromHex(wallet.pk), | |||||
| ALL_BIP143, | |||||
| ), | |||||
| }); | |||||
| } | |||||
| // Build the cancel tx | |||||
| const cancelTxSer = agoraPartial | |||||
| .cancelTx({ | |||||
| // Cashtab one-addr | |||||
| // This works here because we lookup cancelable offers by the same addr | |||||
| // Would need a different approach if Cashtab starts supporting HD wallets | |||||
| cancelSk: fromHex(wallet.sk), | |||||
| fuelInputs: fuelInputs, | |||||
| // Change to user addr | |||||
| recipientScript: Script.p2pkh(fromHex(wallet.hash)), | |||||
| feePerKb: satsPerKb, | feePerKb: satsPerKb, | ||||
| }) | dustSats: BigInt(appConfig.dustSats), | ||||
| .ser(); | }); | ||||
| // Convert to hex | if (!broadcastResult.success) { | ||||
| // Note that broadcastTx will accept cancelTxSer | throw new Error( | ||||
| // But hex is a better way to store raw txs for integration tests | `Cancel transaction failed: ${broadcastResult.errors?.join(', ')}`, | ||||
| const hex = toHex(cancelTxSer); | ); | ||||
| } | |||||
| // Broadcast the cancel tx | const txid = broadcastResult.broadcasted[0]; | ||||
| let resp; | |||||
| try { | |||||
| console.log('OrderBook cancel - hex:', hex); | |||||
| console.log('OrderBook cancel - satsPerKb:', settings.satsPerKb); | |||||
| resp = await chronik.broadcastTx(hex); | |||||
| console.log('OrderBook cancel - resp:', resp); | |||||
| toast( | toast( | ||||
| <a | <a | ||||
| href={`${explorer.blockExplorerUrl}/tx/${resp.txid}`} | href={`${explorer.blockExplorerUrl}/tx/${txid}`} | ||||
| target="_blank" | target="_blank" | ||||
| rel="noopener noreferrer" | rel="noopener noreferrer" | ||||
| > | > | ||||
| Canceled listing | Canceled listing | ||||
| </a>, | </a>, | ||||
| { | { | ||||
| icon: <TokenIcon size={32} tokenId={tokenId} />, | icon: <TokenIcon size={32} tokenId={tokenId} />, | ||||
| }, | }, | ||||
| Show All 13 Lines | const acceptOffer = async (agoraPartial: PartialOffer) => { | ||||
| if (preparedTokenSatoshis === null) { | if (preparedTokenSatoshis === null) { | ||||
| // We cannot accept an offer if we do not have valid preparedTokenSatoshis | // We cannot accept an offer if we do not have valid preparedTokenSatoshis | ||||
| // Should never happen as we disable the buy button in this case | // Should never happen as we disable the buy button in this case | ||||
| return; | return; | ||||
| } | } | ||||
| // Determine tx fee from settings | // Determine tx fee from settings | ||||
| const satsPerKb: bigint = BigInt(settings.satsPerKb); | const satsPerKb: bigint = BigInt(settings.satsPerKb); | ||||
| // Potential input utxos for this transaction | |||||
| // non-token utxos that are spendable | |||||
| const eligibleUtxos = ignoreUnspendableUtxos( | |||||
| wallet.state.nonSlpUtxos, | |||||
| chaintipBlockheight, | |||||
| ); | |||||
| let acceptFuelInputs; | |||||
| try { | |||||
| acceptFuelInputs = getAgoraPartialAcceptFuelInputs( | |||||
| agoraPartial, | |||||
| eligibleUtxos, | |||||
| preparedTokenSatoshis, | |||||
| satsPerKb, | |||||
| ) as CashtabUtxo[]; | |||||
| } catch (err) { | |||||
| console.error( | |||||
| 'Error determining fuel inputs for offer accept', | |||||
| err, | |||||
| ); | |||||
| // Hide the confirmation modal | |||||
| setShowConfirmBuyModal(false); | |||||
| // Error notification | |||||
| return toast.error(`${err}`); | |||||
| } | |||||
| const signedFuelInputs = []; | |||||
| for (const fuelUtxo of acceptFuelInputs) { | |||||
| // Sign and prep utxos for ecash-lib inputs | |||||
| signedFuelInputs.push({ | |||||
| input: { | |||||
| prevOut: { | |||||
| txid: fuelUtxo.outpoint.txid, | |||||
| outIdx: fuelUtxo.outpoint.outIdx, | |||||
| }, | |||||
| signData: { | |||||
| sats: fuelUtxo.sats, | |||||
| outputScript: Script.p2pkh(fromHex(wallet.hash)), | |||||
| }, | |||||
| }, | |||||
| signatory: P2PKHSignatory( | |||||
| fromHex(wallet.sk), | |||||
| fromHex(wallet.pk), | |||||
| ALL_BIP143, | |||||
| ), | |||||
| }); | |||||
| } | |||||
| // Use an arbitrary sk, pk for the convenant | |||||
| let acceptTxSer; | |||||
| try { | try { | ||||
| acceptTxSer = agoraPartial | // Use ecash-agora's take() method which handles fuel inputs and broadcasting via ecash-wallet | ||||
| .acceptTx({ | const broadcastResult = await agoraPartial.take({ | ||||
| wallet: ecashWallet, | |||||
| covenantSk: DUMMY_KEYPAIR.sk, | covenantSk: DUMMY_KEYPAIR.sk, | ||||
| covenantPk: DUMMY_KEYPAIR.pk, | covenantPk: DUMMY_KEYPAIR.pk, | ||||
| fuelInputs: signedFuelInputs, | |||||
| recipientScript: Script.p2pkh(fromHex(wallet.hash)), | |||||
| feePerKb: satsPerKb, | |||||
| acceptedAtoms: preparedTokenSatoshis, | acceptedAtoms: preparedTokenSatoshis, | ||||
| }) | feePerKb: satsPerKb, | ||||
| .ser(); | dustSats: BigInt(appConfig.dustSats), | ||||
| } catch (err) { | }); | ||||
| console.error('Error accepting offer', err); | |||||
| toast.error(`${err}`); | |||||
| return; | |||||
| } | |||||
| // We need hex so we can log it to get integration test mocks | if (!broadcastResult.success) { | ||||
| const hex = toHex(acceptTxSer); | throw new Error( | ||||
| `Accept transaction failed: ${broadcastResult.errors?.join(', ')}`, | |||||
| ); | |||||
| } | |||||
| let resp; | const txid = broadcastResult.broadcasted[0]; | ||||
| try { | |||||
| resp = await chronik.broadcastTx(hex); | |||||
| toast( | toast( | ||||
| <a | <a | ||||
| href={`${explorer.blockExplorerUrl}/tx/${resp.txid}`} | href={`${explorer.blockExplorerUrl}/tx/${txid}`} | ||||
| target="_blank" | target="_blank" | ||||
| rel="noopener noreferrer" | rel="noopener noreferrer" | ||||
| > | > | ||||
| {`Bought ${decimalizedTokenQtyToLocaleFormat( | {`Bought ${decimalizedTokenQtyToLocaleFormat( | ||||
| decimalizeTokenAmount( | decimalizeTokenAmount( | ||||
| preparedTokenSatoshis.toString(), | preparedTokenSatoshis.toString(), | ||||
| decimals as SlpDecimals, | decimals as SlpDecimals, | ||||
| ), | ), | ||||
| Show All 20 Lines | const acceptOffer = async (agoraPartial: PartialOffer) => { | ||||
| if (ws === null) { | if (ws === null) { | ||||
| // Update offers only if we are not already updating them from the ws | // Update offers only if we are not already updating them from the ws | ||||
| fetchAndPrepareActiveOffers(); | fetchAndPrepareActiveOffers(); | ||||
| } | } | ||||
| setShowConfirmBuyModal(false); | setShowConfirmBuyModal(false); | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error('Error accepting offer', err); | console.error('Error accepting offer', err); | ||||
| toast.error(`${err}`); | toast.error(`${err}`); | ||||
| // Hide the confirmation modal on error | |||||
| setShowConfirmBuyModal(false); | |||||
| } | } | ||||
| }; | }; | ||||
| // Syntax shortcut for complex token qty calculation in confirm modal | // Syntax shortcut for complex token qty calculation in confirm modal | ||||
| const getDeltaTokenQtyRow = () => { | const getDeltaTokenQtyRow = () => { | ||||
| if (typeof decimals === 'undefined') { | if (typeof decimals === 'undefined') { | ||||
| // Should never happen, as we only call this when user is making a buy, | // Should never happen, as we only call this when user is making a buy, | ||||
| // and we need token decimals for this to be enabled | // and we need token decimals for this to be enabled | ||||
| ▲ Show 20 Lines • Show All 1,280 Lines • Show Last 20 Lines | |||||