diff --git a/package-lock.json b/package-lock.json index 9a15afa2..ecd6c50e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.6.1", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", - "@mintlayer/sdk": "1.0.37", + "@mintlayer/sdk": "1.0.38", "@noble/secp256k1": "^3.0.0", "bip32": "^5.0.0", "bip39": "^3.1.0", @@ -4729,9 +4729,9 @@ "license": "MIT" }, "node_modules/@mintlayer/sdk": { - "version": "1.0.37", - "resolved": "https://registry.npmjs.org/@mintlayer/sdk/-/sdk-1.0.37.tgz", - "integrity": "sha512-roZgeeShLTVTjyosFi55G1F5mEg4cIxu3WpfKPVPBl44cd9GW/l+lG5+cOkH83DDparq+ix2ZhavP6RlSQFBLg==", + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@mintlayer/sdk/-/sdk-1.0.38.tgz", + "integrity": "sha512-4CzGmEGuq5mSJ9mRVsdFbDPxnhzGzt28lp252qKok1zcRqRiVKGUpVoqnNgXpUudR8hxxFdPGKdKEKSMirHhIQ==", "license": "ISC", "dependencies": { "@mintlayer/wasm-lib": "^0.1.0" diff --git a/package.json b/package.json index e774cc43..3388d914 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", - "@mintlayer/sdk": "1.0.37", + "@mintlayer/sdk": "1.0.38", "@noble/secp256k1": "^3.0.0", "bip32": "^5.0.0", "bip39": "^3.1.0", diff --git a/public/background.js b/public/background.js index c3b82d3c..e0899d23 100644 --- a/public/background.js +++ b/public/background.js @@ -42,129 +42,125 @@ sendResponse({ result: { isConnected: !!connectedSites[origin] }, }) - // External dApp API disabled — uncomment blocks below to re-enable } else if (message.method === 'connect') { - sendResponse({ error: 'External connections are disabled' }) - // if (connectWindowId === false) { - // pendingResponses.set(message.requestId, sendResponse) - // api.windows.create( - // { - // url: api.runtime.getURL('popup.html'), - // type: 'popup', - // width: 800, - // height: 600, - // focused: true, - // }, - // (win) => { - // connectWindowId = win.id - // api.storage.local.set( - // { - // pendingRequest: { - // origin, - // requestId: message.requestId, - // // networkType: message.params.networkType, - // // permission: message.params.permission, - // action: 'connect', - // }, - // }, - // () => { - // if (api.runtime.lastError) { - // console.error( - // '[Mintlayer] Storage set error:', - // api.runtime.lastError, - // ) - // } - // }, - // ) - // }, - // ) - // return true // Keep channel open - // } else if (typeof connectWindowId === 'number') { - // api.windows.update(connectWindowId, { focused: true }) - // sendResponse({ error: 'Connection window already open' }) - // } + if (connectWindowId === false) { + pendingResponses.set(message.requestId, sendResponse) + api.windows.create( + { + url: api.runtime.getURL('popup.html'), + type: 'popup', + width: 800, + height: 600, + focused: true, + }, + (win) => { + connectWindowId = win.id + api.storage.local.set( + { + pendingRequest: { + origin, + requestId: message.requestId, + // networkType: message.params.networkType, + // permission: message.params.permission, + action: 'connect', + }, + }, + () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage set error:', + api.runtime.lastError, + ) + } + }, + ) + }, + ) + return true // Keep channel open + } else if (typeof connectWindowId === 'number') { + api.windows.update(connectWindowId, { focused: true }) + sendResponse({ error: 'Connection window already open' }) + } } else if (message.method === 'signTransaction') { - sendResponse({ error: 'External signing is disabled' }) - // if (!connectedSites[origin]) { - // sendResponse({ error: 'Not connected. Call connect first.' }) - // } else if (popupWindowId === false) { - // pendingResponses.set(message.requestId, sendResponse) - // api.windows.create( - // { - // url: api.runtime.getURL('popup.html'), - // type: 'popup', - // width: 800, - // height: 600, - // focused: true, - // }, - // (win) => { - // popupWindowId = win.id - // api.storage.local.set( - // { - // pendingRequest: { - // origin, - // requestId: message.requestId, - // action: 'signTransaction', - // data: message.params || {}, - // }, - // }, - // () => { - // if (api.runtime.lastError) { - // console.error( - // '[Mintlayer] Storage set error:', - // api.runtime.lastError, - // ) - // } - // }, - // ) - // }, - // ) - // return true - // } else if (typeof popupWindowId === 'number') { - // api.windows.update(popupWindowId, { focused: true }) - // sendResponse({ error: 'Transaction signing window already open' }) - // } + if (!connectedSites[origin]) { + sendResponse({ error: 'Not connected. Call connect first.' }) + } else if (popupWindowId === false) { + pendingResponses.set(message.requestId, sendResponse) + api.windows.create( + { + url: api.runtime.getURL('popup.html'), + type: 'popup', + width: 800, + height: 600, + focused: true, + }, + (win) => { + popupWindowId = win.id + api.storage.local.set( + { + pendingRequest: { + origin, + requestId: message.requestId, + action: 'signTransaction', + data: message.params || {}, + }, + }, + () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage set error:', + api.runtime.lastError, + ) + } + }, + ) + }, + ) + return true + } else if (typeof popupWindowId === 'number') { + api.windows.update(popupWindowId, { focused: true }) + sendResponse({ error: 'Transaction signing window already open' }) + } } else if (message.method === 'signChallenge') { - sendResponse({ error: 'External signing is disabled' }) - // if (!connectedSites[origin]) { - // sendResponse({ error: 'Not connected. Call connect first.' }) - // } else if (popupWindowId === false) { - // pendingResponses.set(message.requestId, sendResponse) - // api.windows.create( - // { - // url: api.runtime.getURL('popup.html'), - // type: 'popup', - // width: 800, - // height: 600, - // focused: true, - // }, - // (win) => { - // popupWindowId = win.id - // api.storage.local.set( - // { - // pendingRequest: { - // origin, - // requestId: message.requestId, - // action: 'signChallenge', - // data: message.params || {}, - // }, - // }, - // () => { - // if (api.runtime.lastError) { - // console.error( - // '[Mintlayer] Storage set error:', - // api.runtime.lastError, - // ) - // } - // }, - // ) - // }, - // ) - // return true - // } else if (typeof popupWindowId === 'number') { - // api.windows.update(popupWindowId, { focused: true }) - // sendResponse({ error: 'Transaction signing window already open' }) - // } + if (!connectedSites[origin]) { + sendResponse({ error: 'Not connected. Call connect first.' }) + } else if (popupWindowId === false) { + pendingResponses.set(message.requestId, sendResponse) + api.windows.create( + { + url: api.runtime.getURL('popup.html'), + type: 'popup', + width: 800, + height: 600, + focused: true, + }, + (win) => { + popupWindowId = win.id + api.storage.local.set( + { + pendingRequest: { + origin, + requestId: message.requestId, + action: 'signChallenge', + data: message.params || {}, + }, + }, + () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage set error:', + api.runtime.lastError, + ) + } + }, + ) + }, + ) + return true + } else if (typeof popupWindowId === 'number') { + api.windows.update(popupWindowId, { focused: true }) + sendResponse({ error: 'Transaction signing window already open' }) + } } else if (message.method === 'version') { sendResponse({ result: api.runtime.getManifest().version }) } else if (message.method === 'getSession') { @@ -187,6 +183,27 @@ } } + // Handle disconnect requests from the wallet UI + if (message.action === 'disconnectSite') { + const targetOrigin = message.origin + + if (!targetOrigin || !connectedSites[targetOrigin]) { + sendResponse({ result: null }) + return + } + + delete connectedSites[targetOrigin] + api.storage.local.set({ connectedSites }, () => { + if (api.runtime.lastError) { + console.error('[Mintlayer] Storage set error:', api.runtime.lastError) + sendResponse({ error: api.runtime.lastError.message }) + return + } + sendResponse({ result: { origin: targetOrigin } }) + }) + return true + } + if (!message.method && message.action !== 'popupResponse') return // Handle popup responses @@ -211,6 +228,11 @@ pendingResponses.delete(requestId) } }) + } else if (!result && message.method === 'connect') { + if (storedSendResponse) { + storedSendResponse({ error: error || 'User rejected the request' }) + pendingResponses.delete(requestId) + } } else if (result && message.method === 'signTransaction_approve') { storedSendResponse({ result, error }) pendingResponses.delete(requestId) diff --git a/public/index.html b/public/index.html index 6896c69c..38b31363 100644 --- a/public/index.html +++ b/public/index.html @@ -10,24 +10,6 @@ rel="icon" href="favicon.ico" /> - - - - { const mask = Expressions.FIELDS.BTC.getExpression( AppInfo.decimalSeparator, AppInfo.thousandsSeparator, + props.decimals, ) const [regexIntegerPartIndex, regexDecimalPartIndex] = [1, 5] diff --git a/src/components/basic/SiteBadge/SiteBadge.module.css b/src/components/basic/SiteBadge/SiteBadge.module.css new file mode 100644 index 00000000..36137ac9 --- /dev/null +++ b/src/components/basic/SiteBadge/SiteBadge.module.css @@ -0,0 +1,36 @@ +.badge { + display: inline-flex; + width: fit-content; + align-items: center; + gap: var(--space-xs); + max-width: 100%; + padding: var(--space-xs) var(--space-md); + border-radius: var(--round-size-big); + border: 1px solid rgba(var(--color-main-green), 0.35); + background: rgba(var(--color-main-green), 0.1); +} + +.dot { + width: 8px; + height: 8px; + flex-shrink: 0; + border-radius: 50%; + background: rgb(var(--mojito-green)); +} + +.origin { + font-size: var(--font-size-sm); + font-weight: 600; + color: rgb(var(--color-black)); + overflow-wrap: anywhere; + line-height: 1.3; +} + +.unknown { + border-color: rgba(var(--color-orange), 0.4); + background: rgba(var(--color-orange), 0.12); +} + +.unknown .dot { + background: rgb(var(--color-orange)); +} diff --git a/src/components/basic/SiteBadge/SiteBadge.tsx b/src/components/basic/SiteBadge/SiteBadge.tsx new file mode 100644 index 00000000..0d83bc7c --- /dev/null +++ b/src/components/basic/SiteBadge/SiteBadge.tsx @@ -0,0 +1,24 @@ +import styles from './SiteBadge.module.css' + +interface SiteBadgeProps { + origin: string + unknown?: boolean +} + +const SiteBadge = ({ origin, unknown = false }: SiteBadgeProps) => { + const badgeStyles = [styles.badge, unknown ? styles.unknown : ''] + .filter(Boolean) + .join(' ') + + return ( +
+ + {origin} +
+ ) +} + +export default SiteBadge diff --git a/src/components/basic/SkeletonLoader/SkeletonLoader.css b/src/components/basic/SkeletonLoader/SkeletonLoader.css index d65ebfb9..614fdf1f 100644 --- a/src/components/basic/SkeletonLoader/SkeletonLoader.css +++ b/src/components/basic/SkeletonLoader/SkeletonLoader.css @@ -1,5 +1,6 @@ .card { display: flex; + align-items: center; padding: 12px 30px 12px 18px; margin-bottom: 0.75rem; background-color: rgb(var(--color-gray)); diff --git a/src/components/basic/SkeletonLoader/SkeletonLoader.js b/src/components/basic/SkeletonLoader/SkeletonLoader.js index fd2b7f53..af38be29 100644 --- a/src/components/basic/SkeletonLoader/SkeletonLoader.js +++ b/src/components/basic/SkeletonLoader/SkeletonLoader.js @@ -25,7 +25,7 @@ const SkeletonLoader = ({ variant = 'default' }) => { data-testid="card" >
{ const isDelegationWithdraw = @@ -124,6 +125,7 @@ const CryptoFiatField = ({
{ const { balanceLoading } = useContext(AccountContext) - const { client } = useContext(MintlayerContext) + const fillOrder = useFillOrder() const { networkType } = useContext(SettingsContext) const [orderId, setOrderId] = useState('') const [orderIdValidity, setOrderIdValidity] = useState(false) @@ -54,7 +55,7 @@ const ManualSwap = () => { try { setLoading(true) if (isFormValid) { - await client.fillOrder({ + await fillOrder({ order_id: orderId, amount, destination: destinationAddress, diff --git a/src/components/composed/SwapInterface/SwapInterface.js b/src/components/composed/SwapInterface/SwapInterface.js index b9da5b81..329f210e 100644 --- a/src/components/composed/SwapInterface/SwapInterface.js +++ b/src/components/composed/SwapInterface/SwapInterface.js @@ -61,7 +61,7 @@ const SwapInterface = () => { const handleSubmit = async (e) => { e.preventDefault() - if (!amount || isNaN(amount) || parseFloat(amount) <= 0) { + if (amount && (isNaN(amount) || parseFloat(amount) <= 0)) { console.error('Invalid amount') return } @@ -132,7 +132,7 @@ const SwapInterface = () => { /> + + ))} + + ) +} + +export default SettingsConnections diff --git a/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css index e5953524..c6dd24ab 100644 --- a/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css +++ b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css @@ -9,8 +9,9 @@ } .preview-section h3 { - margin: 0 0 16px; - font-size: 1.2rem; + margin: 0 0 var(--space-md); + font-size: var(--font-size-xl); + font-weight: 700; display: flex; align-items: center; gap: 8px; @@ -109,18 +110,36 @@ .transactionDetails { display: flex; flex-direction: column; - gap: 16px; } .signTxSection { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; - background: #f8fafc; - border-radius: 8px; + gap: var(--space-3xs); + padding: var(--space-sm) 0; + border-bottom: 1px solid rgba(var(--color-black), 0.06); +} + +.signTxSection:last-child { + border-bottom: none; +} + +.signTxSection h4 { + margin: 0; + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(var(--color-black), 0.45); +} + +.signTxSection p { + margin: 0; + font-size: var(--font-size-md); + overflow-wrap: break-word; } .signTxAction { - color: rgb(var(--color-primary)); + color: rgb(var(--color-stats-green)); + font-weight: 600; } diff --git a/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.js b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.js index 077332ac..83c87471 100644 --- a/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.js +++ b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.js @@ -2,60 +2,12 @@ import React, { useContext } from 'react' import { SignTransaction as SignTxHelpers } from '@Helpers' import './ExternalTransactionPreview.css' -import { AccountContext } from '@Contexts' +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' -// Error Boundary Component for TransactionPreview -class TransactionPreviewErrorBoundary extends React.Component { - constructor(props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error) { - return { hasError: true, error } - } - - componentDidCatch(error, errorInfo) { - console.error('TransactionPreview error:', error, errorInfo) - } - - render() { - if (this.state.hasError) { - return ( -
-
-
-

Transaction Preview

-
-
-
-

Unable to display transaction details

-

- An error occurred while parsing the transaction data. Please - try again or contact support. -

-
- {this.props.basicInfo && ( - <> -
-

Request from:

-

{this.props.basicInfo.origin || 'Unknown'}

-
-
-

Request id:

-

{this.props.basicInfo.requestId || 'Unknown'}

-
- - )} -
-
-
- ) - } - - return this.props.children - } -} +import TransactionBreakdown from '../TransactionBreakdown/TransactionBreakdown' +import TransactionPreviewErrorBoundary from '../TransactionPreviewErrorBoundary/TransactionPreviewErrorBoundary' +import UnrecognizedOperation from '../UnrecognizedOperation/UnrecognizedOperation' const findRelevantOutput = (inputs, outputs, requiredAddresses) => { const inputWithToken = inputs.find( @@ -79,7 +31,7 @@ const findRelevantOutput = (inputs, outputs, requiredAddresses) => { const EstimatedChanges = ({ action }) => { return (
-

Estimated changes:

+

Estimated changes

You’re approving a one-time request to{' '} {action} @@ -91,9 +43,9 @@ const EstimatedChanges = ({ action }) => { const RequestDetails = ({ transactionData }) => { return (

-

Request from:

+

Request from

{transactionData.origin}

-

Request id:

+

Request id

{transactionData.requestId}

) @@ -108,7 +60,7 @@ const NetworkFee = ({ transactionData }) => { } return (
-

Network fee:

+

Network fee

{fee}

) @@ -155,7 +107,7 @@ const TransferDetails = ({ transactionData, requiredAddresses }) => {

Unable to determine transfer details

{inputWithToken && ( <> -

Token id:

+

Token id

{tokenId}

)} @@ -168,15 +120,15 @@ const TransferDetails = ({ transactionData, requiredAddresses }) => {
-

Destination:

+

Destination

{outputToUse.destination || 'Unknown'}

{inputWithToken && ( <> -

Token id:

+

Token id

{tokenId}

)} -

Amount:

+

Amount

{outputToUse.value?.amount?.decimal || 'Unknown'}

@@ -192,14 +144,14 @@ const FreezeTokenDetails = ({ transactionData, unfreeze }) => { return (
-

Estimated changes:

+

Estimated changes

You’re approving a one-time request to{' '} {unfreeze ? 'unfreeze' : 'freeze'} token

-

Token id:

+

Token id

{inputWithToken.input.token_id}

@@ -216,11 +168,11 @@ const ChangeTokenMetadata = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

-

New metadata:

+

New metadata

{inputWithToken.input.new_metadata_uri}

@@ -237,11 +189,11 @@ const ChangeTokenAuthority = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

-

New authority:

+

New authority

{inputWithToken.input.new_authority}

@@ -259,7 +211,7 @@ const LockTokenSupply = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

@@ -280,7 +232,7 @@ const BurnToken = ({ transactionData }) => { {tokenId ? (
-

Token id:

+

Token id

{tokenId}

) : ( @@ -313,7 +265,7 @@ const ConcludeOrder = ({ transactionData }) => {
-

Order ID:

+

Order ID

{inputWithOrderID.input.order_id}

@@ -341,7 +293,7 @@ const FillOrder = ({ transactionData }) => {
-

Order id:

+

Order id

{inputWithOrderID.input.order_id}

@@ -359,20 +311,20 @@ const CreateOrder = ({ transactionData }) => {
-

Ask balance:

+

Ask balance

{outputWithCreateOrder.ask_balance.decimal}

-

Ask currency:

+

Ask currency

{outputWithCreateOrder.ask_currency.type}

-

Destination:

+

Destination

{outputWithCreateOrder.conclude_destination}

-

Give balance:

+

Give balance

{outputWithCreateOrder.give_balance.decimal}

-

Give currency:

+

Give currency

Token id: {outputWithCreateOrder.give_currency.token_id}

type: {outputWithCreateOrder.give_currency.type}

-

Initially asked:

+

Initially asked

{outputWithCreateOrder.initially_asked.decimal}

-

Initially given:

+

Initially given

{outputWithCreateOrder.initially_given.decimal}

@@ -389,23 +341,23 @@ const IssueToken = ({ transactionData }) => {
-

Authority:

+

Authority

{outputWithCreateOrder.authority}

-

Freezable:

+

Freezable

{outputWithCreateOrder.is_freezable ? 'True' : 'False'}

-

Metadata:

+

Metadata

Hex: {outputWithCreateOrder.metadata_uri.hex}

String: {outputWithCreateOrder.metadata_uri.string}

-

Number of decimals:

+

Number of decimals

{outputWithCreateOrder.number_of_decimals}

-

Token ticker:

+

Token ticker

Hex: {outputWithCreateOrder.token_ticker.hex}

String: {outputWithCreateOrder.token_ticker.string}

-

Supply type:

+

Supply type

{outputWithCreateOrder.total_supply.type}

{outputWithCreateOrder.total_supply?.amount?.decimal && ( <> -

Total supply:

+

Total supply

{outputWithCreateOrder.total_supply.amount.decimal}

)} @@ -424,7 +376,7 @@ const IssueNft = ({ transactionData }) => {
-

Destination:

+

Destination

{outputWithIssueNft.destination}

@@ -441,7 +393,7 @@ const DataDeposit = ({ transactionData }) => {
-

Data:

+

Data

{outputWithDataDeposit.data}

@@ -458,7 +410,7 @@ const CreateDelegationId = ({ transactionData }) => {
-

Pool Id:

+

Pool Id

{outputWithDataDeposit.pool_id}

@@ -475,9 +427,9 @@ const DelegateStaking = ({ transactionData }) => {
-

Delegation Id:

+

Delegation Id

{outputWithDataDeposit.delegation_id}

-

Amount:

+

Amount

{outputWithDataDeposit.amount.decimal}

@@ -494,9 +446,9 @@ const DelegateWithdraw = ({ transactionData }) => {
-

Delegation Id:

+

Delegation Id

{inputWithWithdraw.delegation_id}

-

Amount:

+

Amount

{inputWithWithdraw.amount.decimal}

@@ -513,15 +465,15 @@ const CreateHtlc = ({ transactionData }) => {
-

Amount:

+

Amount

{outputWithHtlc.value.amount.decimal}

-

Secret Hash:

+

Secret Hash

{outputWithHtlc.htlc.secret_hash.hex}

-

Spend Key:

+

Spend Key

{outputWithHtlc.htlc.spend_key}

-

Refund Key:

+

Refund Key

{outputWithHtlc.htlc.refund_key}

-

Refund Timelock:

+

Refund Timelock

{outputWithHtlc.htlc.refund_timelock.type}:{' '} {outputWithHtlc.htlc.refund_timelock.content} @@ -541,15 +493,15 @@ const SpendHtlc = ({ transactionData }) => {

-

Amount:

+

Amount

{inputWithHtlc.utxo.value.amount.decimal}

-

Secret Hash:

+

Secret Hash

{inputWithHtlc.utxo.htlc.secret_hash.hex}

-

Spend Key:

+

Spend Key

{inputWithHtlc.utxo.htlc.spend_key}

-

Refund Key:

+

Refund Key

{inputWithHtlc.utxo.htlc.refund_key}

-

Refund Timelock:

+

Refund Timelock

{inputWithHtlc.utxo.htlc.refund_timelock.type}:{' '} {inputWithHtlc.utxo.htlc.refund_timelock.content} @@ -573,9 +525,9 @@ const BridgeRequest = ({ transactionData }) => {

-

Token id:

+

Token id

{inputsWithTokens[0]?.utxo?.value?.token_id || 'Unknown'}

-

Amount:

+

Amount

{outputsWithTokens[0]?.value?.amount?.decimal || 'Unknown'}

@@ -592,7 +544,7 @@ const BurnCoin = ({ transactionData }) => {
-

Amount:

+

Amount

{outputWithBurnCoin.value.amount.decimal}

@@ -609,9 +561,9 @@ const TokenMint = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithMint.input.token_id}

-

Amount:

+

Amount

{inputWithMint.input.amount.decimal}

@@ -628,9 +580,9 @@ const TokenUnmint = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithUnmint.input.token_id}

-

Amount:

+

Amount

{inputWithUnmint.input.amount.decimal}

@@ -647,21 +599,21 @@ const TokenMintWithLock = ({ transactionData }) => {
-

Destination:

+

Destination

{outputWithLock.destination}

-

Amount:

+

Amount

{outputWithLock.value.amount.decimal}

{outputWithLock.value.token_id && ( <> -

Token id:

+

Token id

{outputWithLock.value.token_id}

)} -

Lock type:

+

Lock type

{outputWithLock.lock.type}

{outputWithLock.lock.content && ( <> -

Lock details:

+

Lock details

{JSON.stringify(outputWithLock.lock.content)}

)} @@ -673,8 +625,16 @@ const TokenMintWithLock = ({ transactionData }) => { const SummaryView = ({ data }) => { const { flags, transactionData } = SignTxHelpers.getTransactionDetails(data) const { addresses } = useContext(AccountContext) + const { tokenMap } = useContext(MintlayerContext) + const { networkType } = useContext(SettingsContext) const requiredAddresses = addresses.mlAddresses.mlChangeAddresses + const ownAddresses = { + receiving: addresses.mlAddresses.mlReceivingAddresses, + change: addresses.mlAddresses.mlChangeAddresses, + } + const coinTicker = + networkType === AppInfo.NETWORK_TYPES.TESTNET ? 'TML' : 'ML' return (
@@ -822,6 +782,15 @@ const SummaryView = ({ data }) => { /> )} + {flags.isUnknown && } + + +
diff --git a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css index e5953524..c6dd24ab 100644 --- a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css +++ b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css @@ -9,8 +9,9 @@ } .preview-section h3 { - margin: 0 0 16px; - font-size: 1.2rem; + margin: 0 0 var(--space-md); + font-size: var(--font-size-xl); + font-weight: 700; display: flex; align-items: center; gap: 8px; @@ -109,18 +110,36 @@ .transactionDetails { display: flex; flex-direction: column; - gap: 16px; } .signTxSection { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; - background: #f8fafc; - border-radius: 8px; + gap: var(--space-3xs); + padding: var(--space-sm) 0; + border-bottom: 1px solid rgba(var(--color-black), 0.06); +} + +.signTxSection:last-child { + border-bottom: none; +} + +.signTxSection h4 { + margin: 0; + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(var(--color-black), 0.45); +} + +.signTxSection p { + margin: 0; + font-size: var(--font-size-md); + overflow-wrap: break-word; } .signTxAction { - color: rgb(var(--color-primary)); + color: rgb(var(--color-stats-green)); + font-weight: 600; } diff --git a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.js b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.js index ddeb811d..68456f71 100644 --- a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.js +++ b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.js @@ -2,7 +2,12 @@ import React, { useEffect, useContext } from 'react' import { SignTransaction as SignTxHelpers } from '@Helpers' import './InternalTransactionPreview.css' -import { AccountContext, MintlayerContext } from '@Contexts' +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' + +import TransactionBreakdown from '../TransactionBreakdown/TransactionBreakdown' +import TransactionPreviewErrorBoundary from '../TransactionPreviewErrorBoundary/TransactionPreviewErrorBoundary' +import UnrecognizedOperation from '../UnrecognizedOperation/UnrecognizedOperation' const findRelevantOutput = (inputs, outputs, requiredAddresses) => { const inputWithToken = inputs.find( @@ -24,7 +29,7 @@ const findRelevantOutput = (inputs, outputs, requiredAddresses) => { const EstimatedChanges = ({ action }) => { return (
-

Estimated changes:

+

Estimated changes

{action}

@@ -36,7 +41,7 @@ const NetworkFee = ({ fee }) => { if (!fee) return '' return (
-

Network fee:

+

Network fee

{fee}

) @@ -74,15 +79,15 @@ const TransferDetails = ({ transactionData, requiredAddresses }) => {
-

Destination:

+

Destination

{relevantOutput.destination}

{inputWithToken && ( <> -

Token id:

+

Token id

{tokenId}

)} -

Amount:

+

Amount

{relevantOutput.value.amount.decimal}

@@ -109,14 +114,14 @@ const FreezeTokenDetails = ({ transactionData, unfreeze }) => { return (
-

Estimated changes:

+

Estimated changes

You’re approving a one-time request to{' '} {unfreeze ? 'unfreeze' : 'freeze'} token

-

Token id:

+

Token id

{inputWithToken.input.token_id}

@@ -145,11 +150,11 @@ const ChangeTokenMetadata = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

-

New metadata:

+

New metadata

{inputWithToken.input.new_metadata_uri}

@@ -168,11 +173,11 @@ const ChangeTokenAuthority = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

-

New authority:

+

New authority

{inputWithToken.input.new_authority}

@@ -192,7 +197,7 @@ const LockTokenSupply = ({ transactionData }) => {
-

Token id:

+

Token id

{inputWithToken.input.token_id}

@@ -215,7 +220,7 @@ const BurnToken = ({ transactionData }) => { {tokenId ? (
-

Token id:

+

Token id

{tokenId}

) : ( @@ -238,7 +243,7 @@ const ConcludeOrder = ({ transactionData }) => {
-

Order ID:

+

Order ID

{inputWithOrderID.input.order_id}

@@ -256,7 +261,7 @@ const FillOrder = ({ transactionData }) => {
-

Order id:

+

Order id

{inputWithOrderID.input.order_id}

@@ -276,20 +281,20 @@ const CreateOrder = ({ transactionData }) => {
-

Ask balance:

+

Ask balance

{outputWithCreateOrder.ask_balance.decimal}

-

Ask currency:

+

Ask currency

{outputWithCreateOrder.ask_currency.type}

-

Destination:

+

Destination

{outputWithCreateOrder.conclude_destination}

-

Give balance:

+

Give balance

{outputWithCreateOrder.give_balance.decimal}

-

Give currency:

+

Give currency

Token id: {outputWithCreateOrder.give_currency.token_id}

type: {outputWithCreateOrder.give_currency.type}

-

Initially asked:

+

Initially asked

{outputWithCreateOrder.initially_asked.decimal}

-

Initially given:

+

Initially given

{outputWithCreateOrder.initially_given.decimal}

@@ -308,23 +313,23 @@ const IssueToken = ({ transactionData }) => {
-

Authority:

+

Authority

{outputWithCreateOrder.authority}

-

Freezable:

+

Freezable

{outputWithCreateOrder.is_freezable ? 'True' : 'False'}

-

Metadata:

+

Metadata

Hex: {outputWithCreateOrder.metadata_uri.hex}

String: {outputWithCreateOrder.metadata_uri.string}

-

Number of decimals:

+

Number of decimals

{outputWithCreateOrder.number_of_decimals}

-

Token ticker:

+

Token ticker

Hex: {outputWithCreateOrder.token_ticker.hex}

String: {outputWithCreateOrder.token_ticker.string}

-

Supply type:

+

Supply type

{outputWithCreateOrder.total_supply.type}

{outputWithCreateOrder.total_supply?.amount?.decimal && ( <> -

Total supply:

+

Total supply

{outputWithCreateOrder.total_supply.amount.decimal}

)} @@ -345,7 +350,7 @@ const IssueNft = ({ transactionData }) => {
-

Destination:

+

Destination

{outputWithIssueNft.destination}

@@ -364,7 +369,7 @@ const DataDeposit = ({ transactionData }) => {
-

Data:

+

Data

{outputWithDataDeposit.data}

@@ -392,7 +397,7 @@ const CreateDelegationId = ({ transactionData }) => {
-

Pool Id:

+

Pool Id

{outputWithCreateDelegation.pool_id}

@@ -421,9 +426,9 @@ const DelegateStaking = ({ transactionData }) => {
-

Delegation Id:

+

Delegation Id

{outputWithStaking.delegation_id}

-

Amount:

+

Amount

{outputWithStaking.amount.decimal}

@@ -451,9 +456,9 @@ const DelegateWithdraw = ({ transactionData }) => {
-

Delegation Id:

+

Delegation Id

{inputWithWithdraw.delegation_id}

-

Amount:

+

Amount

{inputWithWithdraw.amount.decimal}

@@ -476,9 +481,9 @@ const BridgeRequest = ({ transactionData }) => {
-

Token id:

+

Token id

{inputsWithTokens[0].utxo.value.token_id}

-

Amount:

+

Amount

{outputsWithTokens[0].value.amount.decimal}

@@ -489,8 +494,16 @@ const BridgeRequest = ({ transactionData }) => { const SummaryView = ({ data }) => { const { flags, transactionData } = SignTxHelpers.getTransactionDetails(data) const { addresses } = useContext(AccountContext) + const { tokenMap } = useContext(MintlayerContext) + const { networkType } = useContext(SettingsContext) const requiredAddresses = addresses.mlAddresses.mlChangeAddresses + const ownAddresses = { + receiving: addresses.mlAddresses.mlReceivingAddresses, + change: addresses.mlAddresses.mlChangeAddresses, + } + const coinTicker = + networkType === AppInfo.NETWORK_TYPES.TESTNET ? 'TML' : 'ML' return (
@@ -551,6 +564,15 @@ const SummaryView = ({ data }) => { requiredAddresses={requiredAddresses} /> )} + + {flags.isUnknown && } + +
) @@ -558,9 +580,11 @@ const SummaryView = ({ data }) => { const InternalTransactionPreview = ({ data }) => { return ( -
- -
+ +
+ +
+
) } diff --git a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js new file mode 100644 index 00000000..22a5366c --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js @@ -0,0 +1,301 @@ +import Decimal from 'decimal.js' + +import styles from './TransactionBreakdown.module.css' + +const COIN_KEY = 'Coin' +const COIN_DECIMALS = 11 + +// keeps room for the largest balances and never falls back to exponents +const Amount = Decimal.clone({ precision: 40, toExpNeg: -30, toExpPos: 30 }) + +const formatTotal = (total, isCoin) => + (isCoin ? total.toDecimalPlaces(COIN_DECIMALS) : total).toFixed() + +const shortenId = (id) => + id && id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id + +const MAX_FIELD_DEPTH = 2 +const MAX_FIELD_LENGTH = 80 + +const shortenData = (data) => + data && data.length > MAX_FIELD_LENGTH + ? `${data.slice(0, MAX_FIELD_LENGTH)}…` + : data + +const getAmount = (source) => + source?.value?.amount?.decimal ?? + source?.amount?.decimal ?? + (typeof source?.amount === 'string' ? source.amount : null) + +const getAsset = (source, tokenMap, coinTicker) => { + const value = source?.value || source + if (value?.type === 'Coin') { + return { key: COIN_KEY, label: coinTicker } + } + + const tokenId = value?.token_id || source?.token_id + if (tokenId) { + return { key: tokenId, label: tokenMap[tokenId] || shortenId(tokenId) } + } + + // account inputs carry a bare amount, and those are always coins + return getAmount(source) ? { key: COIN_KEY, label: coinTicker } : null +} + +const getOwnership = (address, ownAddresses) => { + if (!address) return null + if (ownAddresses.receiving?.includes(address)) return 'Your address' + if (ownAddresses.change?.includes(address)) return 'Your change address' + return null +} + +const DESCRIBED_FIELDS = [ + 'type', + 'value', + 'destination', + 'amount', + 'input_type', + 'index', + 'source_id', + 'source_type', + 'command', + 'account_type', +] + +const getExtraFields = (source) => + Object.entries(source || {}).filter( + ([key, value]) => !DESCRIBED_FIELDS.includes(key) && value !== undefined, + ) + +const LOCK_LABELS = { + ForBlockCount: (content) => `for ${content} blocks`, + UntilTime: (content) => `until ${content}`, + ForSeconds: (content) => `for ${content} seconds`, + UntilHeight: (content) => `until block ${content}`, +} + +const formatLock = (lock) => { + const label = LOCK_LABELS[lock?.type] + return label ? label(lock.content) : null +} + +const FieldValue = ({ value, depth = 0 }) => { + if (value === null) return 'null' + + // the chain encodes text fields as a hex/string pair, only the text reads + if (value?.string !== undefined && value?.hex !== undefined) { + return {shortenData(String(value.string))} + } + + if (typeof value !== 'object') { + const text = String(value) + return {shortenData(text)} + } + + if (depth >= MAX_FIELD_DEPTH) { + const text = JSON.stringify(value) + return {shortenData(text)} + } + + return ( + + {Object.entries(value).map(([key, nested]) => ( + + {key} + + + ))} + + ) +} + +const ExtraFields = ({ source }) => { + const fields = getExtraFields(source) + if (fields.length === 0) return null + + return ( +
+ {fields.map(([key, value]) => { + const lock = key === 'lock' ? formatLock(value) : null + + return ( +
+
{key}
+
+ {lock || } +
+
+ ) + })} +
+ ) +} + +const Entry = ({ title, amount, asset, address, ownership, source }) => ( +
  • +
    + + {title} + {ownership && ( + <> + {' · '} + {ownership} + + )} + + {address && {address}} + +
    + {amount && ( + + {amount} {asset?.label} + + )} +
  • +) + +const getBalanceChanges = ({ + inputs, + outputs, + ownAddresses, + tokenMap, + coinTicker, +}) => { + const changes = new Map() + + const apply = (source, address, sign) => { + if (!getOwnership(address, ownAddresses)) return + const asset = getAsset(source, tokenMap, coinTicker) + const amount = getAmount(source) + if (!asset || !amount) return + + const current = changes.get(asset.key) || { + key: asset.key, + label: asset.label, + total: new Amount(0), + } + current.total = current.total.plus(new Amount(amount).times(sign)) + changes.set(asset.key, current) + } + + inputs.forEach((input) => { + if (input.input?.input_type !== 'UTXO') return + apply(input.utxo, input.utxo?.destination, -1) + }) + + outputs.forEach((output) => apply(output, output.destination, 1)) + + return [...changes.values()].filter(({ total }) => !total.isZero()) +} + +const TransactionBreakdown = ({ + JSONRepresentation, + ownAddresses = {}, + tokenMap = {}, + coinTicker = 'ML', +}) => { + const inputs = JSONRepresentation?.inputs || [] + const outputs = JSONRepresentation?.outputs || [] + + const balanceChanges = getBalanceChanges({ + inputs, + outputs, + ownAddresses, + tokenMap, + coinTicker, + }) + + return ( +
    +
    + Balance change + {balanceChanges.length > 0 ? ( + <> +
      + {balanceChanges.map(({ key, label, total }) => ( +
    • + {total.isNegative() ? '−' : '+'} + {formatTotal(total.abs(), key === COIN_KEY)} {label} +
    • + ))} +
    + + The network fee is part of this amount + + + ) : ( +

    + This transaction does not move funds held by this wallet. +

    + )} +
    + +
    +

    Inputs ({inputs.length})

    +
      + {inputs.map((input, index) => { + const isUtxo = input.input?.input_type === 'UTXO' + const source = isUtxo ? input.utxo : input.input + const address = isUtxo + ? input.utxo?.destination + : input.input?.destination + const title = isUtxo + ? input.utxo?.type || 'UTXO' + : input.input?.command || + input.input?.account_type || + input.input?.input_type + + return ( + + ) + })} +
    +
    + +
    +

    Outputs ({outputs.length})

    +
      + {outputs.map((output, index) => ( + + ))} +
    +
    +
    + ) +} + +export default TransactionBreakdown diff --git a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css new file mode 100644 index 00000000..bf5ef061 --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css @@ -0,0 +1,166 @@ +.breakdown { + display: flex; + flex-direction: column; + gap: var(--space-xl); + margin-top: var(--space-lg); +} + +.hero { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + padding: var(--space-lg); + border: 1px solid rgba(var(--color-light-green), 0.35); + border-radius: 14px; + background: rgba(var(--color-light-green), 0.06); +} + +.heroLabel { + font-size: var(--font-size-sm); + color: rgba(var(--color-black), 0.55); +} + +.heroList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3xs); +} + +.heroList li { + font-size: var(--font-size-4xl); + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.positive { + color: rgb(var(--color-stats-green)); +} + +.negative { + color: rgb(var(--color-red)); +} + +.heroNote, +.heroEmpty { + margin: 0; + font-size: var(--font-size-sm); + color: rgba(var(--color-black), 0.55); +} + +.group { + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.groupHead { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: 0; + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(var(--color-black), 0.45); +} + +.groupHead::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(var(--color-black), 0.08); +} + +.rows { + list-style: none; + margin: 0; + padding: 0; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3xs) var(--space-sm); + padding: var(--space-sm) 0; + border-bottom: 1px solid rgba(var(--color-black), 0.06); +} + +.row:last-child { + border-bottom: none; +} + +.rowMain { + display: flex; + flex: 1 1 60%; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.rowTitle { + font-size: var(--font-size-md); + font-weight: 600; +} + +.rowOwn { + font-weight: 500; + color: rgb(var(--color-stats-green)); +} + +.rowAddress { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--font-size-xs); + color: rgba(var(--color-black), 0.5); + word-break: break-all; +} + +.rowAmount { + flex: 0 0 auto; + margin-left: auto; + font-size: var(--font-size-md); + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.fields { + display: flex; + flex-direction: column; + gap: 2px; + margin: var(--space-3xs) 0 0; +} + +.field { + display: flex; + gap: var(--space-2xs); + font-size: var(--font-size-xs); +} + +.fieldKey { + flex-shrink: 0; + color: rgba(var(--color-black), 0.45); +} + +.fieldValue { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + word-break: break-all; +} + +.nested { + display: flex; + flex-direction: column; + gap: 1px; +} + +.nestedLine { + display: flex; + gap: var(--space-2xs); +} diff --git a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.test.js b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.test.js new file mode 100644 index 00000000..a42edc3f --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.test.js @@ -0,0 +1,120 @@ +import { render, screen } from '@testing-library/react' + +import { MOCKS } from '../../../../pages/SignExternalTransaction/mocks' +import TransactionBreakdown from './TransactionBreakdown' + +const jsonOf = (mock) => mock.request.data.txData.JSONRepresentation + +const renderBreakdown = (mock, ownAddresses = {}) => + render( + , + ) + +describe('TransactionBreakdown', () => { + it('lists every input and output of the transaction', () => { + const json = jsonOf(MOCKS.transfer) + renderBreakdown(MOCKS.transfer) + + expect( + screen.getByText(`Inputs (${json.inputs.length})`), + ).toBeInTheDocument() + expect( + screen.getByText(`Outputs (${json.outputs.length})`), + ).toBeInTheDocument() + expect(screen.getAllByText('Transfer')).toHaveLength( + json.inputs.length + json.outputs.length, + ) + }) + + it('shows the amount that leaves the wallet, fee included', () => { + // 17071.486043 spent, 17059.486043 returned as change, 10 sent, 2 fee + renderBreakdown(MOCKS.transfer, { + receiving: ['tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6'], + change: [], + }) + + expect(screen.getByTestId('balance-change')).toHaveTextContent( + '\u221212 TML', + ) + }) + + it('marks the addresses that belong to the wallet', () => { + renderBreakdown(MOCKS.transfer, { + receiving: ['tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6'], + change: [], + }) + + expect(screen.getAllByText('Your address')).toHaveLength(2) + }) + + it('counts what a delegation withdrawal brings back', () => { + renderBreakdown(MOCKS.delegationWithdraw, { + receiving: ['tmt1q9l0g4kd3s6x5rmesaznegz06pw9hxu6qvqu3pa7'], + change: [], + }) + + expect(screen.getByTestId('balance-change')).toHaveTextContent('+8 TML') + expect(screen.getByText('DelegationBalance')).toBeInTheDocument() + }) + + it('keeps coin amounts at eleven decimals and out of exponent notation', () => { + const own = 'tmt1qown' + render( + , + ) + + expect(screen.getByTestId('balance-change')).toHaveTextContent( + '\u22120.00000000001 TML', + ) + }) + + it('spells out a lock instead of dumping its json', () => { + renderBreakdown(MOCKS.delegationWithdraw) + + expect(screen.getByText('for 7200 blocks')).toBeInTheDocument() + expect(screen.queryByText(/ForBlockCount/)).not.toBeInTheDocument() + }) + + it('opens nested fields into readable lines', () => { + renderBreakdown(MOCKS.issueNft) + + expect(screen.getAllByText('name').length).toBeGreaterThan(0) + }) + + it('says so when no funds of this wallet move', () => { + renderBreakdown(MOCKS.transfer) + + expect( + screen.getByText( + 'This transaction does not move funds held by this wallet.', + ), + ).toBeInTheDocument() + }) +}) diff --git a/src/components/containers/SignTransaction/TransactionPreviewErrorBoundary/TransactionPreviewErrorBoundary.js b/src/components/containers/SignTransaction/TransactionPreviewErrorBoundary/TransactionPreviewErrorBoundary.js new file mode 100644 index 00000000..d5c5db61 --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionPreviewErrorBoundary/TransactionPreviewErrorBoundary.js @@ -0,0 +1,56 @@ +import React from 'react' + +// Error Boundary Component for TransactionPreview +class TransactionPreviewErrorBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error) { + return { hasError: true, error } + } + + componentDidCatch(error, errorInfo) { + console.error('TransactionPreview error:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( +
    +
    +
    +

    Transaction Preview

    +
    +
    +
    +

    Unable to display transaction details

    +

    + An error occurred while parsing the transaction data. Please + try again or contact support. +

    +
    + {this.props.basicInfo && ( + <> +
    +

    Request from

    +

    {this.props.basicInfo.origin || 'Unknown'}

    +
    +
    +

    Request id

    +

    {this.props.basicInfo.requestId || 'Unknown'}

    +
    + + )} +
    +
    +
    + ) + } + + return this.props.children + } +} + +export default TransactionPreviewErrorBoundary diff --git a/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.js b/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.js new file mode 100644 index 00000000..b05be140 --- /dev/null +++ b/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.js @@ -0,0 +1,16 @@ +import styles from './UnrecognizedOperation.module.css' + +const UnrecognizedOperation = () => ( +
    +

    This operation is not recognized

    +

    + The wallet cannot name what this transaction does. Read the inputs and + outputs below, and sign it only if you know what you are approving. +

    +
    +) + +export default UnrecognizedOperation diff --git a/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.module.css b/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.module.css new file mode 100644 index 00000000..8829b588 --- /dev/null +++ b/src/components/containers/SignTransaction/UnrecognizedOperation/UnrecognizedOperation.module.css @@ -0,0 +1,19 @@ +.notice { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + padding: var(--space-sm); + border: 1px solid rgba(var(--color-red), 0.4); + border-radius: 8px; + background: rgba(var(--color-red), 0.06); +} + +.title { + margin: 0; + font-size: var(--font-size-md); +} + +.text { + margin: 0; + font-size: var(--font-size-sm); +} diff --git a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.js b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.js index 3451a268..4b3d3733 100644 --- a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.js +++ b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.js @@ -6,6 +6,7 @@ import { CryptoFiatField } from '@ComposedComponents' import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-swap.svg' import { ReactComponent as ArrowIcon } from '@Assets/images/icon-arrow-down.svg' import { ML } from '@Helpers' +import { useFillOrder } from '@Hooks' import { MintlayerContext } from '@Contexts' @@ -89,8 +90,9 @@ const SwapInfoContent = ({ order, from }) => { } const OrderDetails = ({ order }) => { - const { client, unusedAddresses, balance, tokenBalances } = + const { unusedAddresses, balance, tokenBalances } = useContext(MintlayerContext) + const fillOrder = useFillOrder() const [txErrorMessage, setTxErrorMessage] = useState(null) const [loading, setLoading] = useState(false) const [amount, setAmount] = useState('') @@ -135,7 +137,7 @@ const OrderDetails = ({ order }) => { try { setLoading(true) if (order) { - await client.fillOrder({ + await fillOrder({ order_id: order.order_id, amount, destination: unusedAddresses.receive, diff --git a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js index 2bde1986..0c365650 100644 --- a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js +++ b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js @@ -10,6 +10,13 @@ import { } from '@Contexts' import { ML } from '@Helpers' +const mockFillOrder = jest.fn() + +jest.mock('@Hooks', () => ({ + ...jest.requireActual('@Hooks'), + useFillOrder: () => mockFillOrder, +})) + describe('OrderDetailsItem', () => { it('renders with title and content', () => { const title = 'Test Title' @@ -296,9 +303,6 @@ const mockCoinOrder = { } const mockMintlayerContext = { - client: { - fillOrder: jest.fn(), - }, unusedAddresses: { receive: 'testnet_addr1', }, @@ -389,7 +393,7 @@ describe('OrderDetails', () => { fireEvent.click(swapButton) await waitFor(() => { - expect(mockMintlayerContext.client.fillOrder).toHaveBeenCalledWith({ + expect(mockFillOrder).toHaveBeenCalledWith({ order_id: 'order123456789', amount: '50', destination: 'testnet_addr1', diff --git a/src/components/containers/Wallet/TransactionButton.js b/src/components/containers/Wallet/TransactionButton.js index 3449439a..2a7fbce2 100644 --- a/src/components/containers/Wallet/TransactionButton.js +++ b/src/components/containers/Wallet/TransactionButton.js @@ -2,7 +2,7 @@ import { ReactComponent as ArrowIcon } from '@Assets/images/icon-arrow-down.svg' import { ReactComponent as DelegationIcon } from '@Assets/images/icon-delegation.svg' import { ReactComponent as SignIcon } from '@Assets/images/icon-sign.svg' import { ReactComponent as NftIcon } from '@Assets/images/icon-nft.svg' -import { ReactComponent as SwapIcon } from '@Assets/images/icon-arrow-swap.svg' +import { ReactComponent as SwapIcon } from '@Assets/images/icon-loop.svg' import { ReactComponent as AddressesIcon } from '@Assets/images/icon-inbox.svg' import { Button } from '@BasicComponents' diff --git a/src/components/containers/index.js b/src/components/containers/index.js index 41c73bb8..b87a8ccb 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -33,6 +33,7 @@ import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet.tsx' import SettingsAbout from './Settings/SettingsAbout/SettingsAbout.tsx' import SettingsBackup from './Settings/SettingsBackup/SettingsBackup' import SettingsSection from './Settings/SettingsSection/SettingsSection.tsx' +import SettingsConnections from './Settings/SettingsConnections/SettingsConnections.tsx' import SignMessage from './Message/SignMessage/SignMessage' import VerifyMessage from './Message/VerifyMessage/VerifyMessage' @@ -75,6 +76,7 @@ const Settings = { SettingsDelete, SettingsBackup, SettingsSection, + SettingsConnections, } const RestoreAccount = { diff --git a/src/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index 11033e57..ae8e80ce 100644 --- a/src/contexts/MintlayerProvider/MintlayerProvider.js +++ b/src/contexts/MintlayerProvider/MintlayerProvider.js @@ -87,6 +87,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { const coinTicker = networkType === AppInfo.NETWORK_TYPES.TESTNET ? 'TML' : 'ML' const swapPairsCurrency = orderPair.split('_') + const minAskBalance = Number(amount) || 0 const ordersPairInfo = await Mintlayer.getOrdersListByPair(orderPair) if (!ordersPairInfo || ordersPairInfo.length === 0) { console.log('No orders found for this pair') @@ -101,7 +102,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { swapPairsCurrency[0] === coinTicker) || (order.ask_currency.token_id && order.ask_currency.token_id === swapPairsCurrency[0])) && - Number(order.ask_balance.decimal) >= Number(amount) + Number(order.ask_balance.decimal) >= minAskBalance ) { acc.push({ ...order, diff --git a/src/hooks/UseFillOrder/useFillOrder.js b/src/hooks/UseFillOrder/useFillOrder.js new file mode 100644 index 00000000..33482895 --- /dev/null +++ b/src/hooks/UseFillOrder/useFillOrder.js @@ -0,0 +1,43 @@ +import { useCallback, useContext } from 'react' + +import { MintlayerContext } from '@Contexts' +import { Mintlayer } from '@APIs' + +const useFillOrder = () => { + const { client, utxos } = useContext(MintlayerContext) + + const fillOrder = useCallback( + async ({ order_id, amount, destination }) => { + const order_details = JSON.parse(await Mintlayer.getOrderById(order_id)) + + const [ask_token_details, give_token_details] = await Promise.all([ + order_details.ask_currency.type === 'Coin' + ? null + : Mintlayer.getTokenById(order_details.ask_currency.token_id), + order_details.give_currency.type === 'Coin' + ? null + : Mintlayer.getTokenById(order_details.give_currency.token_id), + ]) + + const transaction = await client.buildTransaction({ + type: 'FillOrder', + params: { + order_id, + amount, + destination, + order_details, + ask_token_details, + give_token_details, + }, + ...(utxos.length ? { opts: { withUTXO: utxos } } : {}), + }) + + return client.signTransaction(transaction) + }, + [client, utxos], + ) + + return fillOrder +} + +export default useFillOrder diff --git a/src/hooks/index.js b/src/hooks/index.js index 43f14763..6304ed8c 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -5,6 +5,7 @@ import useMlWalletInfo from './UseWalletInfo/useMlWalletInfo' import useExchangeRates from './UseExchangeRates/useExchangeRates' import useOneDayAgoExchangeRates from './UseOneDayAgoExchangeRates/useOneDayAgoExchangeRates' import useMediaQuery from './useMediaQuery/useMediaQuery' +import useFillOrder from './UseFillOrder/useFillOrder' export { useStyleClasses, @@ -14,4 +15,5 @@ export { useExchangeRates, useOneDayAgoExchangeRates, useMediaQuery, + useFillOrder, } diff --git a/src/index.js b/src/index.js index 32c14bde..943e9e7a 100644 --- a/src/index.js +++ b/src/index.js @@ -62,6 +62,7 @@ import { import { ML } from '@Cryptos' import { LocalStorageService } from '@Storage' +import '@Assets/styles/fonts.css' import '@Assets/styles/constants.css' import '@Assets/styles/index.css' @@ -106,14 +107,19 @@ const App = () => { useContext(MintlayerContext) const { networkType } = useContext(SettingsContext) const [nextAfterUnlock, setNextAfterUnlock] = useState(null) - const [request, setRequest] = useState(null) + const [, setRequest] = useState(null) const currentMlAddresses = addresses.mlAddresses const isConnectionAvailable = async (accountUnlocked) => { try { const mintlayerResponse = await Mintlayer.getChainTip() - const exchangeResponse = await ExchangeRates.getRate('ml', 'usd') + const exchangeResponse = await ExchangeRates.getRate('ml', 'usd').catch( + (error) => { + console.error('Exchange rates unavailable:', error) + return null + }, + ) return !!mintlayerResponse && !!exchangeResponse } catch (error) { if (accountUnlocked) { @@ -197,38 +203,48 @@ const App = () => { if (!unlocked) { setNextAfterUnlock({ route: '/connect', - state: { action: 'connect', origin, requestId, request }, + state: { + action: 'connect', + origin, + requestId, + request: pendingRequest, + }, }) return } navigate('/connect', { - state: { action: 'connect', origin, requestId, request }, + state: { + action: 'connect', + origin, + requestId, + request: pendingRequest, + }, }) } if (action === 'signTransaction') { - if (request.data.chain === 'bitcoin') { + if (pendingRequest.data.chain === 'bitcoin') { if (!unlocked) { setNextAfterUnlock({ route: '/wallet/Bitcoin/sign-transaction', - state: { action: 'signTransaction', request }, + state: { action: 'signTransaction', request: pendingRequest }, }) return } navigate('/wallet/Bitcoin/sign-transaction', { - state: { action: 'signTransaction', request }, + state: { action: 'signTransaction', request: pendingRequest }, }) } else { if (!unlocked) { setNextAfterUnlock({ route: '/wallet/Mintlayer/sign-external-transaction', - state: { action: 'signTransaction', request }, + state: { action: 'signTransaction', request: pendingRequest }, }) return } navigate('/wallet/Mintlayer/sign-external-transaction', { - state: { action: 'signTransaction', request }, + state: { action: 'signTransaction', request: pendingRequest }, }) } } @@ -237,12 +253,12 @@ const App = () => { if (!unlocked) { setNextAfterUnlock({ route: '/wallet/Mintlayer/sign-challenge', - state: { action: 'signChallenge', request }, + state: { action: 'signChallenge', request: pendingRequest }, }) return } navigate('/wallet/Mintlayer/sign-challenge', { - state: { action: 'signChallenge', request }, + state: { action: 'signChallenge', request: pendingRequest }, }) } @@ -252,8 +268,8 @@ const App = () => { route: '/wallet/Mintlayer/staking/create-delegation', state: { action: 'createDelegate', - pool_id: request.data.pool_id, - referral_code: request.data.referral_code || '', + pool_id: pendingRequest.data.pool_id, + referral_code: pendingRequest.data.referral_code || '', }, }) storage.local.remove('pendingRequest', () => { @@ -270,8 +286,8 @@ const App = () => { navigate('/wallet/Mintlayer/staking/create-delegation', { state: { action: 'createDelegate', - pool_id: request.data.pool_id, - referral_code: request.data.referral_code || '', + pool_id: pendingRequest.data.pool_id, + referral_code: pendingRequest.data.referral_code || '', }, }) storage.local.remove('pendingRequest', () => { diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css index 092514f3..1704da69 100644 --- a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css @@ -1,4 +1,6 @@ .signTransaction { + display: flex; + flex-direction: column; background-color: #ffffff; margin: 0 auto; font-family: 'Arial', sans-serif; @@ -27,7 +29,7 @@ margin-bottom: 20px; display: flex; flex-direction: column; - height: 70%; + flex-grow: 1; @media screen and (min-width: 901px) { height: 80%; @@ -46,6 +48,7 @@ justify-content: center; gap: 12px; bottom: 0; + flex-shrink: 0; } .modalTitle { diff --git a/src/pages/ConnectionPage/BitcoinDataNotice.module.css b/src/pages/ConnectionPage/BitcoinDataNotice.module.css new file mode 100644 index 00000000..74d7762a --- /dev/null +++ b/src/pages/ConnectionPage/BitcoinDataNotice.module.css @@ -0,0 +1,69 @@ +.section { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; +} + +.toggleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.toggleText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.toggleTitle { + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--color-black)); +} + +.toggleDescription { + font-size: var(--font-size-sm); + color: rgba(var(--color-black), 0.55); +} + +.infoBlock { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-md); + border-radius: 12px; + border: 1px solid rgba(var(--color-main-green), 0.3); + background: rgba(var(--color-main-green), 0.08); +} + +.infoIcon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 1px; + border-radius: 50%; + background: rgb(var(--color-main-green)); + color: rgb(var(--color-white)); + font-size: var(--font-size-xs); + font-weight: bold; + font-style: italic; +} + +.infoText { + margin: 0; + font-size: var(--font-size-sm); + line-height: 1.45; + color: rgb(var(--color-dark-gray)); +} + +.infoText strong { + color: rgb(var(--color-main-green)); + font-weight: 600; +} diff --git a/src/pages/ConnectionPage/BitcoinDataNotice.tsx b/src/pages/ConnectionPage/BitcoinDataNotice.tsx new file mode 100644 index 00000000..8744e482 --- /dev/null +++ b/src/pages/ConnectionPage/BitcoinDataNotice.tsx @@ -0,0 +1,43 @@ +import { Toggle } from '@BasicComponents' + +import styles from './BitcoinDataNotice.module.css' + +interface BitcoinDataNoticeProps { + provideBitcoinData: boolean + onToggle: (value: boolean) => void +} + +const BitcoinDataNotice = ({ + provideBitcoinData, + onToggle, +}: BitcoinDataNoticeProps) => { + return ( +
    +
    +
    + Provide Bitcoin data + + Addresses and public keys + +
    + +
    + +
    + i +

    + Note: This option is mandatory when connecting to + HTLC Atomic Swaps dApps. It provides both Bitcoin addresses and public + keys required for cross-chain transactions. +

    +
    +
    + ) +} + +export default BitcoinDataNotice diff --git a/src/pages/ConnectionPage/ConnectionPage.css b/src/pages/ConnectionPage/ConnectionPage.css deleted file mode 100644 index d041986d..00000000 --- a/src/pages/ConnectionPage/ConnectionPage.css +++ /dev/null @@ -1,165 +0,0 @@ -.connect-page__form { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - width: 100%; - height: 100%; - margin: 1rem; - padding: 0 2rem; - /* border: 1px solid #ddd; */ - border-radius: 10px; - /* background-color: #fff; */ - /* box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); */ -} - -.connect-page__title { - display: flex; - flex-direction: column; - gap: 1rem; - align-items: center; -} - -.connect-page__title h2 { - font-size: 1.25rem; - font-weight: bold; -} - -.connect-page__content { - display: flex; - width: 90%; - flex-direction: column; - align-items: center; - gap: 2rem; -} - -.connect-page__description { - font-size: 0.95rem; - margin-bottom: 1rem; -} - -.connect-page__host { - font-weight: 600; - color: rgb(var(--color-black)); -} - -.connect-page__permissions { - position: relative; - text-align: left; - padding-left: 7rem; - list-style: disc !important; - max-width: 400px; - margin: 0 auto; - margin-bottom: 20px; -} - -.connect-page__permissions li { - margin-bottom: 0.5rem; - font-size: 0.9rem; - list-style: inside; -} - -.connect-page__remember { - display: flex; - align-items: center; - gap: 0.5rem; - justify-content: center; - font-size: 0.85rem; - margin-bottom: 1.5rem; -} - -.connect-page__checkbox { - transform: scale(1.1); -} - -.connect-page__actions { - display: flex; - justify-content: space-around; - width: 80%; - gap: 1rem; -} - -.rejectButton { - background-color: #eee; - color: #333; -} - -.connect-page__button--reject:hover { - background-color: #ddd; -} - -.connect-page__button--connect { - background-color: #3cb371; - color: #fff; -} - -.connect-page__button--connect:hover { - background-color: #35a164; -} - -.connectButton { - width: 50%; -} - -.connect-page__icon { - position: absolute; - width: 90px; - height: auto; - left: 4px; - top: 50%; - transform: translate(0, -50%); - stroke: rgb(var(--color-green)); -} - -.connect-page__bitcoin-section { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - width: 100%; -} - -.connect-page__bitcoin-toggle { - display: flex; - align-items: center; - gap: 1rem; -} - -.connect-page__info-block { - display: flex; - align-items: flex-start; - gap: 0.75rem; - padding: 1rem; - background-color: rgba(var(--color-main-green), 0.1); - border: 1px solid rgba(var(--color-main-green), 0.3); - border-radius: 8px; - width: 100%; -} - -.connect-page__info-icon { - width: 20px; - height: 20px; - flex-shrink: 0; - margin-top: 2px; - background-color: rgb(var(--color-main-green)); - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: bold; - font-style: italic; -} - -.connect-page__info-text { - font-size: 0.85rem; - line-height: 1.4; - margin: 0; - color: rgb(var(--color-dark-gray)); -} - -.connect-page__info-text strong { - color: rgb(var(--color-main-green)); - font-weight: 600; -} diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index 358c2aed..06b57c95 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -1,10 +1,15 @@ /* eslint-disable no-undef */ -import './ConnectionPage.css' import { useLocation } from 'react-router' import { useContext, useState } from 'react' import { AccountContext } from '@Contexts' -import { Button, Toggle, PageWrapper } from '@BasicComponents' +import { Button, PageWrapper, SiteBadge } from '@BasicComponents' import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' +import { ReactComponent as IconEye } from '@Assets/images/icon-eye.svg' +import { ReactComponent as IconSign } from '@Assets/images/icon-sign.svg' +import { ReactComponent as IconLoop } from '@Assets/images/icon-loop.svg' +import PermissionItem from './PermissionItem' +import BitcoinDataNotice from './BitcoinDataNotice' +import styles from './ConnectionPage.module.css' const toHexString = (obj) => { return Object.values(obj) @@ -39,8 +44,9 @@ export const ConnectionPage = () => { const permissions = state?.request?.permissions || [] const requireBTC = permissions.includes('bitcoin') + const isUnknownOrigin = origin === website - const connectButtonExtraStyles = ['connectButton'] + const connectButtonExtraStyles = [styles.actionButton] const handleConnect = () => { const remember = document.querySelector('.connect-page__checkbox')?.checked @@ -78,11 +84,10 @@ export const ConnectionPage = () => { ), publicKeys: { receiving: addresses?.btcAddresses?.btcReceivingAddresses.map( - (addr) => - Buffer.from(Object.values(addr)[0].pubkey).toString('hex'), + (addr) => toHexString(Object.values(addr)[0].pubkey), ), change: addresses?.btcAddresses?.btcChangeAddresses.map((addr) => - Buffer.from(Object.values(addr)[0].pubkey).toString('hex'), + toHexString(Object.values(addr)[0].pubkey), ), }, }, @@ -162,80 +167,89 @@ export const ConnectionPage = () => { return (
    -
    -

    - Connect Website to Your Mojito Wallet -

    -

    - The website {origin} is - requesting access to your wallet. -

    -
    +
    +
    + + + +

    + Connect website to your Mojito wallet +

    +

    + This website is requesting access to your wallet. +

    + +
    + +
    + + This will allow the website to + -
    -
    -
      - -
    • View your public addresses
    • -
    • Request transaction signing
    • -
    • Track connection status
    • +
        + + +
      {requireBTC && ( - <> -
      -
      -
      Provide Bitcoin data (addresses AND public keys)
      - -
      - -
      -
      i
      -

      - Note: This option is mandatory when - connecting to HTLC Atomic Swaps dApps. It provides both - Bitcoin addresses and public keys required for cross-chain - transactions. -

      -
      -
      - +
      + +
      )}
    - {/* // TODO: Make this work */} - {/* */} - -
    - - -
    +

    + Only connect to websites you trust. You can reject this request and + nothing will be shared. +

    +
    + + {/* // TODO: Make this work */} + {/* */} + +
    + +
    diff --git a/src/pages/ConnectionPage/ConnectionPage.module.css b/src/pages/ConnectionPage/ConnectionPage.module.css new file mode 100644 index 00000000..72845535 --- /dev/null +++ b/src/pages/ConnectionPage/ConnectionPage.module.css @@ -0,0 +1,148 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-lg); + width: 100%; + max-width: 480px; + margin-top: var(--space-3xl); + align-self: center; + flex: 1; + min-height: 0; +} + +.scrollArea { + display: flex; + flex-direction: column; + gap: var(--space-lg); + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.intro { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + text-align: center; +} + +.shield { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + flex-shrink: 0; + border-radius: 18px; + background: rgba(var(--color-main-green), 0.12); + color: rgb(var(--mojito-green)); +} + +.shieldIcon { + width: 28px; + height: 28px; + stroke: rgb(var(--mojito-green)); +} + +.title { + margin: 0; + font-size: var(--font-size-3xl); + font-weight: 700; + line-height: 1.25; + color: rgb(var(--color-black)); +} + +.subtitle { + margin: 0; + font-size: var(--font-size-md); + line-height: 1.4; + color: rgba(var(--color-black), 0.6); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-lg); + border-radius: var(--round-size); + border: 1px solid rgba(var(--color-black), 0.07); + background: rgb(var(--surface)); + box-shadow: var(--shadow-sm); +} + +.cardTitle { + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(var(--color-black), 0.45); +} + +.permissions { + display: flex; + flex-direction: column; + gap: var(--space-lg); + margin: 0; + padding: 0; + list-style: none; +} + +.bitcoinSlot { + padding-top: var(--space-lg); + border-top: 1px solid rgba(var(--color-black), 0.07); +} + +.disclaimer { + margin: 0; + text-align: center; + font-size: var(--font-size-sm); + line-height: 1.45; + color: rgba(var(--color-black), 0.5); +} + +.actions { + display: flex; + gap: var(--space-md); + width: 100%; + flex-shrink: 0; +} + +.actionButton.actionButton { + flex: 1; + min-width: 0; + padding: var(--space-md) var(--space-lg); + font-size: var(--font-size-md); +} + +@media screen and (max-width: 319px) { + .actions { + flex-direction: column-reverse; + } +} + +@media screen and (min-width: 901px) { + .form { + gap: var(--space-xl); + max-width: 520px; + } + + .title { + font-size: var(--font-size-4xl); + } + + .card { + padding: var(--space-2xl); + } + + .shield { + width: 64px; + height: 64px; + } + + .shieldIcon { + width: 32px; + height: 32px; + } +} diff --git a/src/pages/ConnectionPage/PermissionItem.module.css b/src/pages/ConnectionPage/PermissionItem.module.css new file mode 100644 index 00000000..4fb7329a --- /dev/null +++ b/src/pages/ConnectionPage/PermissionItem.module.css @@ -0,0 +1,55 @@ +.item { + display: flex; + align-items: flex-start; + gap: var(--space-md); + list-style: none; +} + +.iconWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 10px; + background: rgba(var(--color-main-green), 0.12); + color: rgb(var(--mojito-green)); +} + +.icon { + width: 18px; + height: 18px; +} + +.text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.title { + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--color-black)); + line-height: 1.3; +} + +.description { + font-size: var(--font-size-sm); + color: rgba(var(--color-black), 0.55); + line-height: 1.4; +} + +@media screen and (min-width: 901px) { + .iconWrapper { + width: 36px; + height: 36px; + } + + .icon { + width: 20px; + height: 20px; + } +} diff --git a/src/pages/ConnectionPage/PermissionItem.tsx b/src/pages/ConnectionPage/PermissionItem.tsx new file mode 100644 index 00000000..a123f65e --- /dev/null +++ b/src/pages/ConnectionPage/PermissionItem.tsx @@ -0,0 +1,29 @@ +import { FC, SVGProps } from 'react' + +import styles from './PermissionItem.module.css' + +interface PermissionItemProps { + icon: FC> + title: string + description: string +} + +const PermissionItem = ({ + icon: Icon, + title, + description, +}: PermissionItemProps) => { + return ( +
  • + + + + + {title} + {description} + +
  • + ) +} + +export default PermissionItem diff --git a/src/pages/CreateDelegation/CreateDelegation.js b/src/pages/CreateDelegation/CreateDelegation.js index 6befe6a7..a4e51ea3 100644 --- a/src/pages/CreateDelegation/CreateDelegation.js +++ b/src/pages/CreateDelegation/CreateDelegation.js @@ -1,4 +1,4 @@ -import { useEffect, useContext, useState } from 'react' +import { useCallback, useEffect, useContext, useState } from 'react' import { useLocation, useNavigate } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' @@ -55,13 +55,23 @@ const CreateDelegationPage = () => { const loading = preEnterAddress && (fetchingBalances || fetchingUtxos) + const buildDelegationTransaction = useCallback( + ({ pool_id, destination }) => + client.buildTransaction({ + type: 'CreateDelegationId', + params: { pool_id, destination }, + ...(utxos.length ? { opts: { withUTXO: utxos } } : {}), + }), + [client, utxos], + ) + useEffect(() => { const buildTransaction = async () => { if (transaction_conditions && transactionInformation?.to.length > 0) { setFeeLoading(true) try { const unusedReceivingAddress = unusedAddresses.receive - const transaction = await client.buildDelegationCreate({ + const transaction = await buildDelegationTransaction({ pool_id: transactionInformation.to, destination: unusedReceivingAddress, }) @@ -74,7 +84,12 @@ const CreateDelegationPage = () => { } } buildTransaction() - }, [transaction_conditions, transactionInformation, client, unusedAddresses]) + }, [ + transaction_conditions, + transactionInformation, + buildDelegationTransaction, + unusedAddresses, + ]) if (!accountID) { console.log('No account id.') @@ -89,10 +104,11 @@ const CreateDelegationPage = () => { const confirmMlTransaction = async () => { const unusedReceivingAddress = unusedAddresses.receive - const result = await client.delegationCreate({ + const transaction = await buildDelegationTransaction({ pool_id: transactionInformation.to, destination: unusedReceivingAddress, }) + const result = await client.signTransaction(transaction) if (result) { await fetchDelegations() diff --git a/src/pages/CreateRestore/CreateRestore.module.css b/src/pages/CreateRestore/CreateRestore.module.css index 82b4ca4a..5324fdeb 100644 --- a/src/pages/CreateRestore/CreateRestore.module.css +++ b/src/pages/CreateRestore/CreateRestore.module.css @@ -97,14 +97,16 @@ .createButton { width: 100%; - padding: var(--space-lg) var(--space-2xl); - height: 46px; + padding: 0 var(--space-2xl); + height: 52px; + min-height: 52px; font-size: var(--font-size-lg); } .restoreButton { width: 100%; - padding: var(--space-lg) var(--space-2xl); - height: 46px; + padding: 0 var(--space-2xl); + height: 52px; + min-height: 52px; font-size: var(--font-size-lg); } diff --git a/src/pages/DelegationStake/DelegationStake.js b/src/pages/DelegationStake/DelegationStake.js index 6ffc06a5..b3d1cbcb 100644 --- a/src/pages/DelegationStake/DelegationStake.js +++ b/src/pages/DelegationStake/DelegationStake.js @@ -1,4 +1,4 @@ -import { useEffect, useContext, useState } from 'react' +import { useCallback, useEffect, useContext, useState } from 'react' import { useNavigate, useParams } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' @@ -57,6 +57,16 @@ const DelegationStakePage = () => { const loading = fetchingBalances || fetchingUtxos + const buildStakeTransaction = useCallback( + ({ amount, delegation_id }) => + client.buildTransaction({ + type: 'DelegateStaking', + params: { delegation_id, amount }, + ...(utxos.length ? { opts: { withUTXO: utxos } } : {}), + }), + [client, utxos], + ) + useEffect(() => { const buildTransaction = async () => { if ( @@ -66,7 +76,7 @@ const DelegationStakePage = () => { ) { setFeeLoading(true) try { - const transaction = await client.buildDelegationStake({ + const transaction = await buildStakeTransaction({ amount: transactionInformation.amount, delegation_id: transactionInformation.to, }) @@ -82,7 +92,7 @@ const DelegationStakePage = () => { }, [ transaction_conditions, transactionInformation, - client, + buildStakeTransaction, unusedAddresses, delegationId, ]) @@ -98,10 +108,11 @@ const DelegationStakePage = () => { } const confirmMlTransaction = async () => { - const result = await client.delegationStake({ + const transaction = await buildStakeTransaction({ amount: transactionInformation.amount, delegation_id: transactionInformation.to, }) + const result = await client.signTransaction(transaction) return result } diff --git a/src/pages/DelegationWithdraw/DelegationWithdraw.js b/src/pages/DelegationWithdraw/DelegationWithdraw.js index c1ed8251..5cad4b34 100644 --- a/src/pages/DelegationWithdraw/DelegationWithdraw.js +++ b/src/pages/DelegationWithdraw/DelegationWithdraw.js @@ -1,4 +1,4 @@ -import { useEffect, useContext, useState } from 'react' +import { useCallback, useEffect, useContext, useState } from 'react' import { useNavigate, useParams } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' @@ -6,6 +6,7 @@ import { VerticalGroup } from '@LayoutComponents' import { useExchangeRates, useMlWalletInfo } from '@Hooks' import { AccountContext, MintlayerContext, TransactionContext } from '@Contexts' import { AppInfo } from '@Constants' +import { Mintlayer } from '@APIs' import './DelegationWithdraw.css' import { Error, PageWrapper } from '@BasicComponents' @@ -60,6 +61,21 @@ const DelegationWithdrawPage = () => { const loading = fetchingBalances || fetchingUtxos + const buildWithdrawTransaction = useCallback( + async ({ amount, delegation_id }) => { + const [delegation_details] = await Mintlayer.getDelegationDetails([ + delegation_id, + ]) + + return client.buildTransaction({ + type: 'DelegationWithdraw', + params: { delegation_id, amount, delegation_details }, + ...(utxos.length ? { opts: { withUTXO: utxos } } : {}), + }) + }, + [client, utxos], + ) + useEffect(() => { const buildTransaction = async () => { if ( @@ -68,7 +84,7 @@ const DelegationWithdrawPage = () => { transactionInformation?.amount > 0 ) { setFeeLoading(true) - const transaction = await client.buildDelegationWithdraw({ + const transaction = await buildWithdrawTransaction({ amount: transactionInformation.amount, delegation_id: transactionInformation.to, }) @@ -80,7 +96,7 @@ const DelegationWithdrawPage = () => { }, [ transaction_conditions, transactionInformation, - client, + buildWithdrawTransaction, unusedAddresses, delegationId, ]) @@ -96,10 +112,11 @@ const DelegationWithdrawPage = () => { } const confirmMlTransaction = async () => { - const result = await client.delegationWithdraw({ + const transaction = await buildWithdrawTransaction({ amount: transactionInformation.amount, delegation_id: transactionInformation.to, }) + const result = await client.signTransaction(transaction) return result } diff --git a/src/pages/NftSend/NftSend.js b/src/pages/NftSend/NftSend.js index 812e815c..55c81a87 100644 --- a/src/pages/NftSend/NftSend.js +++ b/src/pages/NftSend/NftSend.js @@ -1,4 +1,4 @@ -import { useContext, useState, useEffect, useMemo } from 'react' +import { useCallback, useContext, useState, useEffect, useMemo } from 'react' import { useNavigate, useParams } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' @@ -24,7 +24,8 @@ const NftSendPage = () => { [coinType, tokenId], ) - const { client } = useContext(MintlayerContext) + const { client, utxos, nftInitialUtxos, nftData } = + useContext(MintlayerContext) const currentMlAddresses = addresses.mlAddresses const [totalFeeCrypto, setTotalFeeCrypto] = useState(0) const [feeLoading, setFeeLoading] = useState(false) @@ -60,20 +61,52 @@ const NftSendPage = () => { const [transactionInformation, setTransactionInformation] = useState(null) const { exchangeRate } = useExchangeRates(tokenName, fiatName) + const nftUtxos = useMemo(() => { + const outpointKey = ({ outpoint }) => + `${outpoint.source_id}:${outpoint.index}` + const selectedKeys = new Set(utxos.map(outpointKey)) + return [ + ...utxos, + ...nftInitialUtxos.filter((utxo) => !selectedKeys.has(outpointKey(utxo))), + ] + }, [utxos, nftInitialUtxos]) + + const nftDetails = useMemo(() => { + const nft = nftData.find((item) => item.token_id === tokenId) + return nft ? { ...nft.data, number_of_decimals: 0 } : null + }, [nftData, tokenId]) + + const buildNftTransaction = useCallback( + ({ to }) => { + if (!nftDetails || !nftUtxos.length) { + return client.buildTransferNft({ to, token_id: tokenId }) + } + + return client.buildTransaction({ + type: 'Transfer', + params: { + to, + amount: 1, + token_id: tokenId, + token_details: nftDetails, + }, + opts: { withUTXO: nftUtxos }, + }) + }, + [client, tokenId, nftDetails, nftUtxos], + ) + useEffect(() => { const buildTransaction = async () => { if (transactionInformation?.to.length > 0) { setFeeLoading(true) - const transaction = await client.buildTransferNft({ - to: transactionInformation.to, - token_id: tokenId, - }) + const transaction = await buildNftTransaction(transactionInformation) setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) setFeeLoading(false) } } buildTransaction() - }, [transactionInformation, client, walletType, tokenId]) + }, [transactionInformation, buildNftTransaction]) if (!accountID) { console.log('No account id.') @@ -86,10 +119,8 @@ const NftSendPage = () => { } const confirmMlTransaction = async () => { - const result = await client.transferNft({ - to: transactionInformation.to, - token_id: tokenId, - }) + const transaction = await buildNftTransaction(transactionInformation) + const result = await client.signTransaction(transaction) return result } diff --git a/src/pages/SendMlTransaction/SendMlTransaction.js b/src/pages/SendMlTransaction/SendMlTransaction.js index 5449a516..bbc480ea 100644 --- a/src/pages/SendMlTransaction/SendMlTransaction.js +++ b/src/pages/SendMlTransaction/SendMlTransaction.js @@ -1,4 +1,4 @@ -import { useContext, useState, useEffect, useMemo } from 'react' +import { useCallback, useContext, useState, useEffect, useMemo } from 'react' import { useNavigate, useParams } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' @@ -28,7 +28,7 @@ const SendMlTransactionPage = () => { ) const datahook = useMlWalletInfo - const { client } = useContext(MintlayerContext) + const { client, utxos } = useContext(MintlayerContext) const currentMlAddresses = addresses.mlAddresses const [totalFeeCrypto, setTotalFeeCrypto] = useState(0) const [feeLoading, setFeeLoading] = useState(false) @@ -51,6 +51,10 @@ const SendMlTransactionPage = () => { return tokenBalances[walletType.name].token_info.token_ticker.string } + const tokenDecimals = + walletType.tokenId && + tokenBalances?.[walletType.name]?.token_info?.number_of_decimals + const tokenName = symbol() const fiatName = 'USD' const [transactionData] = useState({ @@ -61,6 +65,25 @@ const SendMlTransactionPage = () => { const [transactionInformation, setTransactionInformation] = useState(null) const { exchangeRate } = useExchangeRates(tokenName, fiatName) + const buildMlTransaction = useCallback( + ({ to, amount }) => { + if (walletType?.tokenId) { + return client.buildTransfer({ + to, + amount, + token_id: walletType.tokenId, + }) + } + + return client.buildTransaction({ + type: 'Transfer', + params: { to, amount }, + ...(utxos?.length ? { opts: { withUTXO: utxos } } : {}), + }) + }, + [client, walletType, utxos], + ) + useEffect(() => { if ( !isFormValid || @@ -77,11 +100,7 @@ const SendMlTransactionPage = () => { const timer = setTimeout(async () => { try { - const transaction = await client.buildTransfer({ - to: transactionInformation.to, - amount: transactionInformation.amount, - token_id: walletType?.tokenId, - }) + const transaction = await buildMlTransaction(transactionInformation) if (cancelled) return setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) } catch (error) { @@ -101,7 +120,7 @@ const SendMlTransactionPage = () => { cancelled = true clearTimeout(timer) } - }, [transactionInformation, client, walletType, isFormValid]) + }, [transactionInformation, buildMlTransaction, isFormValid]) if (!accountID) { console.log('No account id.') @@ -114,11 +133,8 @@ const SendMlTransactionPage = () => { } const confirmMlTransaction = async () => { - const result = await client.transfer({ - to: transactionInformation.to, - amount: transactionInformation.amount, - token_id: walletType?.tokenId, - }) + const transaction = await buildMlTransaction(transactionInformation) + const result = await client.signTransaction(transaction) return result } @@ -152,6 +168,7 @@ const SendMlTransactionPage = () => { isFormValid={isFormValid} goBackToWallet={goBackToWallet} walletType={walletType} + decimals={tokenDecimals || AppInfo.ML_DECIMALS} />
    diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index af02261c..7f1c6cf2 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -23,6 +23,12 @@ const SettingsPage = ({ unlocked }: SettingsPageProps) => { { key: 'delete', component: }, ], }, + { + title: 'Connections', + key: 'connections', + visible: true, + content: , + }, { title: 'About', key: 'about', diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css index 469578f7..2a76407e 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css @@ -1,5 +1,7 @@ /* SignTransaction.css */ .SignTransaction { + display: flex; + flex-direction: column; background-color: #ffffff; margin: 0 auto; font-family: 'Arial', sans-serif; @@ -17,6 +19,7 @@ justify-content: space-between; font-size: 1.5rem; margin-bottom: 20px; + flex-shrink: 0; } .signTxTitle { @@ -28,7 +31,7 @@ margin-bottom: 20px; display: flex; flex-direction: column; - height: 70%; + flex-grow: 1; } .SignTransaction .mock_selector { @@ -89,6 +92,7 @@ justify-content: center; gap: 12px; bottom: 18px; + flex-shrink: 0; } .modal { @@ -225,3 +229,7 @@ margin-top: 4px; color: #6b7280; } + +.requestOrigin { + margin-bottom: 20px; +} diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js index 564f1cde..882bdfe0 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js @@ -1,7 +1,7 @@ /* eslint-disable no-undef */ import { useLocation } from 'react-router' import { MOCKS } from './mocks' -import { Button, PageWrapper } from '@BasicComponents' +import { Button, PageWrapper, SiteBadge } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import { SignTransaction } from '@ContainerComponents' @@ -131,6 +131,7 @@ export const SignBitcoinTransactionPage = () => { }, [initialState]) const state = htlcTransactionState || initialState + const origin = state?.request?.origin const revealed_secret = state?.request?.data?.txData?.JSONRepresentation.secret @@ -440,6 +441,13 @@ export const SignBitcoinTransactionPage = () => {
    +
    + +
    +
    {!external_state && (
    diff --git a/src/pages/SignChallenge/SignChallenge.css b/src/pages/SignChallenge/SignChallenge.css index 4ea89a72..a6efc1e4 100644 --- a/src/pages/SignChallenge/SignChallenge.css +++ b/src/pages/SignChallenge/SignChallenge.css @@ -51,9 +51,14 @@ justify-content: center; padding: 20px; gap: 20px; + flex-shrink: 0; } .SignChallenge .challenge_details .label { font-weight: bold; color: #000; } + +.requestOrigin { + margin-bottom: 20px; +} diff --git a/src/pages/SignChallenge/SignChallenge.js b/src/pages/SignChallenge/SignChallenge.js index bb67b34e..2af1d9c8 100644 --- a/src/pages/SignChallenge/SignChallenge.js +++ b/src/pages/SignChallenge/SignChallenge.js @@ -2,7 +2,7 @@ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers } from '@Helpers' import { MOCKS } from './mocks' -import { Button, PageWrapper } from '@BasicComponents' +import { Button, PageWrapper, SiteBadge } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import './SignChallenge.css' @@ -35,6 +35,7 @@ export const SignChallengePage = () => { const extraButtonStyles = ['buttonSignTransaction'] const state = external_state || MOCKS[selectedMock] + const origin = state?.request?.origin const { addresses, accountID } = useContext(AccountContext) const currentMlAddresses = addresses.mlAddresses @@ -144,6 +145,13 @@ export const SignChallengePage = () => {

    Sign Challenge

    +
    + +
    +
    {!external_state && (
    diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index 720de7de..08ce4ca2 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -1,5 +1,7 @@ /* SignTransaction.css */ .SignTransaction { + display: flex; + flex-direction: column; background-color: #ffffff; margin: 0 auto; font-family: 'Arial', sans-serif; @@ -17,6 +19,7 @@ justify-content: space-between; font-size: 1.5rem; margin-bottom: 20px; + flex-shrink: 0; } .signTxTitle { @@ -28,7 +31,7 @@ margin-bottom: 20px; display: flex; flex-direction: column; - height: 70%; + flex-grow: 1; @media screen and (min-width: 901px) { height: 80%; @@ -95,6 +98,7 @@ justify-content: center; gap: 12px; bottom: 0; + flex-shrink: 0; } .modal { @@ -242,3 +246,7 @@ .secret-hint small { font-style: italic; } + +.requestOrigin { + margin-bottom: 20px; +} diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index c0ff3c9e..6cb0d3f7 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -2,7 +2,7 @@ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers, Secret } from '@Helpers' import { MOCKS } from './mocks' -import { Button, PageWrapper } from '@BasicComponents' +import { Button, PageWrapper, SiteBadge } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import { SignTransaction } from '@ContainerComponents' import { MintlayerContext } from '@Contexts' @@ -54,6 +54,7 @@ export const SignTransactionPage = () => { const [transactionState, setTransactionState] = useState(null) const state = transactionState || external_state || MOCKS[selectedMock] + const origin = state?.request?.origin const { addresses, accountID } = useContext(AccountContext) const currentMlAddresses = addresses.mlAddresses @@ -435,6 +436,13 @@ export const SignTransactionPage = () => {
    +
    + +
    +
    {!external_state && (
    diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index 0b54fb65..efea7c98 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.js +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.js @@ -207,6 +207,9 @@ export const SignTransactionPage = () => { mode: txPreviewInfo.action || 'transfer', poolId: '', delegationId: '', + usedUtxosOutpoints: transactionJSONrepresentation.inputs + .filter(({ input }) => input.input_type === 'UTXO') + .map(({ input: { index, source_id } }) => ({ index, source_id })), }) LocalStorageService.setItem( unconfirmedTransactionString, diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css index e52a44f1..84e33b22 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css @@ -1,4 +1,6 @@ .signTransaction { + display: flex; + flex-direction: column; background-color: #ffffff; margin: 0 auto; font-family: 'Arial', sans-serif; @@ -16,6 +18,7 @@ justify-content: space-between; font-size: 1.5rem; margin-bottom: 20px; + flex-shrink: 0; } .signTxTitle { @@ -27,7 +30,7 @@ margin-bottom: 20px; display: flex; flex-direction: column; - height: 70%; + flex-grow: 1; @media screen and (min-width: 901px) { height: 80%; @@ -93,6 +96,7 @@ justify-content: center; gap: 12px; bottom: 0; + flex-shrink: 0; } .modal { diff --git a/src/utils/Constants/AppInfo/AppInfo.js b/src/utils/Constants/AppInfo/AppInfo.js index 4f67822d..92a73529 100644 --- a/src/utils/Constants/AppInfo/AppInfo.js +++ b/src/utils/Constants/AppInfo/AppInfo.js @@ -13,6 +13,8 @@ const thousandsSeparator = ' ' const amountRegex = /^\d+(.\d+)?$/ const DEFAULT_WALLETS_TO_CREATE = ['btc', 'ml'] const ML_ATOMS_PER_COIN = 100000000000 +const ML_DECIMALS = 11 +const BTC_DECIMALS = 8 const DEFAULT_ML_WALLET_OFFSET = 21 const APPROPRIATE_COST_PER_BLOCK = 190 const APPROPRIATE_MARGIN_RATIO_PER_THOUSAND = 80 @@ -145,6 +147,8 @@ export { DEFAULT_WALLETS_TO_CREATE, NETWORK_TYPES, ML_ATOMS_PER_COIN, + ML_DECIMALS, + BTC_DECIMALS, DEFAULT_ML_WALLET_OFFSET, UNCONFIRMED_TRANSACTION_NAME, ML_TRANSACTION_TYPES, diff --git a/src/utils/Constants/Expressions/Expressions.js b/src/utils/Constants/Expressions/Expressions.js index 1cd7d225..468604e6 100644 --- a/src/utils/Constants/Expressions/Expressions.js +++ b/src/utils/Constants/Expressions/Expressions.js @@ -3,7 +3,7 @@ import { AppInfo } from '@Constants' const rawFieldExpression = { float: '(([0-9]{1,})$|([0-9]{1,3}\\:tsep:{0,})*|([0-9]{1,3}))(\\:dsep:{0,}[0-9]{0,2})?(.{0,})', - btc: '(([0-9]{1,})$|([0-9]{1,3}\\:tsep:{0,})*|([0-9]{1,3}))(\\:dsep:{0,}[0-9]{0,8})?(.{0,})', + btc: '(([0-9]{1,})$|([0-9]{1,3}\\:tsep:{0,})*|([0-9]{1,3}))(\\:dsep:{0,}[0-9]{0,:decimals:})?(.{0,})', } const Expressions = { @@ -25,11 +25,14 @@ const Expressions = { getExpression: ( dSep = AppInfo.decimalSeparator, tSep = AppInfo.thousandsSeparator, + decimals = AppInfo.BTC_DECIMALS, ) => new RegExp( - rawFieldExpression.btc.replaceAll(/:tsep:|:dsep:/g, (char) => - char === ':tsep:' ? tSep : dSep, - ), + rawFieldExpression.btc + .replace(':decimals:', decimals) + .replaceAll(/:tsep:|:dsep:/g, (char) => + char === ':tsep:' ? tSep : dSep, + ), ), }, }, diff --git a/src/utils/Helpers/ML/SignTransaction.js b/src/utils/Helpers/ML/SignTransaction.js index 27ef58a8..71a9065e 100644 --- a/src/utils/Helpers/ML/SignTransaction.js +++ b/src/utils/Helpers/ML/SignTransaction.js @@ -621,131 +621,120 @@ export function getTransactionIntent({ return encodedIntentHash } -export const getTransactionDetails = (transaction) => { - const { txData } = transaction.request.data - - const flags = { - isTransfer: false, - isBridgeRequest: false, - isTokenMint: false, - isTokenUnmint: false, - isTokenMintWithLock: false, - isIssueToken: false, - isCreateOrder: false, - isFillOrder: false, - isConcludeOrder: false, - isBurnCoin: false, - isBurnToken: false, - isLockTokenSupply: false, - isChangeTokenAuthority: false, - isChangeTokenMetadata: false, - isFreezeToken: false, - isUnfreezeToken: false, - isCreateHtlc: false, - isSpendHtlc: false, - isDelegateWithdraw: false, - } +const ACCOUNT_COMMAND_FLAGS = { + MintTokens: 'isTokenMint', + UnmintTokens: 'isTokenUnmint', + LockTokenSupply: 'isLockTokenSupply', + ChangeTokenAuthority: 'isChangeTokenAuthority', + ChangeMetadataUri: 'isChangeTokenMetadata', + FreezeToken: 'isFreezeToken', + UnfreezeToken: 'isUnfreezeToken', + FillOrder: 'isFillOrder', + ConcludeOrder: 'isConcludeOrder', +} - const { JSONRepresentation, intent } = txData +const OUTPUT_FLAGS = { + IssueFungibleToken: 'isIssueToken', + IssueNft: 'isIssueNft', + CreateOrder: 'isCreateOrder', + BurnCoin: 'isBurnCoin', + BurnToken: 'isBurnToken', + DataDeposit: 'isDataDeposit', + CreateDelegationId: 'isCreateDelegationId', + DelegateStaking: 'isDelegateStaking', + Htlc: 'isCreateHtlc', + CreateHtlc: 'isCreateHtlc', +} - if (intent) { - flags.isBridgeRequest = true +const TRANSFER_OUTPUTS = ['Transfer', 'LockThenTransfer'] + +const FLAG_NAMES = [ + 'isTransfer', + 'isBridgeRequest', + 'isTokenMint', + 'isTokenUnmint', + 'isTokenMintWithLock', + 'isIssueToken', + 'isIssueNft', + 'isCreateOrder', + 'isFillOrder', + 'isConcludeOrder', + 'isBurnCoin', + 'isBurnToken', + 'isLockTokenSupply', + 'isChangeTokenAuthority', + 'isChangeTokenMetadata', + 'isFreezeToken', + 'isUnfreezeToken', + 'isCreateHtlc', + 'isSpendHtlc', + 'isDataDeposit', + 'isCreateDelegationId', + 'isDelegateStaking', + 'isDelegateWithdraw', + 'isUnknown', +] + +/** + * Picks the single operation a transaction represents. The order matters: + * an account command describes the transaction better than the outputs it + * produces, and a burn or an issuance better than the transfer next to it. + */ +export const getTransactionType = (JSONRepresentation, intent) => { + const inputs = JSONRepresentation?.inputs || [] + const outputs = JSONRepresentation?.outputs || [] + + const outputTypes = outputs.map((output) => output.type) + const hasOutput = (type) => outputTypes.includes(type) + + if (intent) return 'isBridgeRequest' + + const command = inputs + .map((input) => input.input?.command) + .find((name) => ACCOUNT_COMMAND_FLAGS[name]) + + if (command === 'MintTokens' && hasOutput('LockThenTransfer')) { + return 'isTokenMintWithLock' } + if (command) return ACCOUNT_COMMAND_FLAGS[command] if ( - JSONRepresentation?.inputs?.some((input) => input?.utxo?.type === 'Htlc') + inputs.some((input) => input.input?.account_type === 'DelegationBalance') ) { - flags.isSpendHtlc = true + return 'isDelegateWithdraw' } - JSONRepresentation?.inputs?.forEach((input) => { - if (input.input.account_type === 'DelegationBalance') { - flags.isDelegateWithdraw = true - return - } + if (inputs.some((input) => input.utxo?.type === 'Htlc')) return 'isSpendHtlc' - switch (input.input?.command) { - case 'MintTokens': - flags.isTokenMint = true - break - case 'UnmintTokens': - flags.isTokenUnmint = true - break - case 'LockTokenSupply': - flags.isLockTokenSupply = true - break - case 'ChangeTokenAuthority': - flags.isChangeTokenAuthority = true - break - case 'ChangeMetadataUri': - flags.isChangeTokenMetadata = true - break - case 'FreezeToken': - flags.isFreezeToken = true - break - case 'UnfreezeToken': - flags.isUnfreezeToken = true - break - case 'FillOrder': - flags.isFillOrder = true - break - case 'ConcludeOrder': - flags.isConcludeOrder = true - break - default: - break - } - }) - - JSONRepresentation?.outputs?.forEach((output) => { - switch (output.type) { - case 'LockThenTransfer': - flags.isTokenMintWithLock = true - break - case 'IssueFungibleToken': - flags.isIssueToken = true - break - case 'IssueNft': - flags.isIssueNft = true - break - case 'CreateOrder': - flags.isCreateOrder = true - break - case 'BurnCoin': - flags.isBurnCoin = true - break - case 'BurnToken': - flags.isBurnToken = true - break - case 'DataDeposit': - flags.isDataDeposit = true - break - case 'CreateDelegationId': - flags.isCreateDelegationId = true - break - case 'DelegateStaking': - flags.isDelegateStaking = true - break - case 'Htlc': - flags.isCreateHtlc = true - break - default: - break - } - }) + const outputType = outputTypes.find((type) => OUTPUT_FLAGS[type]) + if (outputType) return OUTPUT_FLAGS[outputType] if ( - !Object.values(flags).some( - (flag, key) => key !== 'isTransfer' && flag === true, - ) + outputs.length > 0 && + outputTypes.every((type) => TRANSFER_OUTPUTS.includes(type)) ) { - flags.isTransfer = true + return 'isTransfer' } + return 'isUnknown' +} + +export const getTransactionDetails = (transaction) => { + const { txData } = transaction.request.data + const { JSONRepresentation, intent } = txData + + const type = getTransactionType(JSONRepresentation, intent) + + const flags = FLAG_NAMES.reduce( + (acc, name) => ({ ...acc, [name]: name === type }), + {}, + ) + const transactionData = transaction.request return { flags, + type, transactionData, } } diff --git a/src/utils/Helpers/ML/SignTransaction.test.js b/src/utils/Helpers/ML/SignTransaction.test.js new file mode 100644 index 00000000..fb4f0e65 --- /dev/null +++ b/src/utils/Helpers/ML/SignTransaction.test.js @@ -0,0 +1,59 @@ +import { getTransactionDetails, getTransactionType } from './SignTransaction' +import { MOCKS } from '../../../pages/SignExternalTransaction/mocks' + +const typeOf = (name) => { + const { txData } = MOCKS[name].request.data + return getTransactionType(txData.JSONRepresentation, txData.intent) +} + +describe('getTransactionType', () => { + it.each([ + ['transfer', 'isTransfer'], + ['transfer_token', 'isTransfer'], + ['transferNft', 'isTransfer'], + ['tokens_mint', 'isTokenMint'], + ['tokensunmint', 'isTokenUnmint'], + ['tokensmint_with_lock', 'isTokenMintWithLock'], + ['issuetoken', 'isIssueToken'], + ['issueNft', 'isIssueNft'], + ['createorder', 'isCreateOrder'], + ['fillorder', 'isFillOrder'], + ['concludeorder', 'isConcludeOrder'], + ['burnToken', 'isBurnToken'], + ['lockTokenSupply', 'isLockTokenSupply'], + ['changeTokenAuthority', 'isChangeTokenAuthority'], + ['changeTokenMetadata', 'isChangeTokenMetadata'], + ['freezeToken', 'isFreezeToken'], + ['unfreezeToken', 'isUnfreezeToken'], + ['dataDeposit', 'isDataDeposit'], + ['delegationCreateId', 'isCreateDelegationId'], + ['delegationStake', 'isDelegateStaking'], + ['delegationWithdraw', 'isDelegateWithdraw'], + ['createHtlc', 'isCreateHtlc'], + ['refundHtlc', 'isSpendHtlc'], + ])('reads %s as %s', (mock, expected) => { + expect(typeOf(mock)).toBe(expected) + }) + + it('reports an unknown operation instead of guessing a transfer', () => { + expect( + getTransactionType({ + inputs: [{ input: { input_type: 'UTXO' } }], + outputs: [{ type: 'SomethingNewFromTheChain' }], + }), + ).toBe('isUnknown') + }) + + it('reports an unknown operation when there are no outputs', () => { + expect(getTransactionType({ inputs: [], outputs: [] })).toBe('isUnknown') + }) + + it('never raises two flags for the same transaction', () => { + Object.entries(MOCKS).forEach(([name, mock]) => { + const { flags } = getTransactionDetails(mock) + const raised = Object.keys(flags).filter((flag) => flags[flag]) + + expect([name, raised]).toEqual([name, [expect.any(String)]]) + }) + }) +})