Repository: vulpemventures/marina (ts)

Issue:
Show the balance of BTC with whitsepace every 3 digits
ie. from 0.00004411 to 0.00 004 411

You could also make the middle triple to be bolder


Pull request description:
show balance with spaces
closes #503 

- updates several balances in the app
- new tests on application.spec

@tiero please review

![Screenshot 2023-12-19 at 18 17 59](https://github.com/vulpemventures/marina/assets/9318412/b5ddfcfb-755a-4132-88b0-563cc074ee43)


Patch:
diff --git a/src/extension/components/balance.tsx b/src/extension/components/balance.tsx
index d296fe8..d5f8c0b 100644
--- a/src/extension/components/balance.tsx
+++ b/src/extension/components/balance.tsx
@@ -5,7 +5,7 @@ import AssetIcon from './assetIcon';
 import { useStorageContext } from '../context/storage-context';
 
 interface Props {
-  assetBalance: string | number;
+  assetBalance: string;
   assetTicker: string;
   assetHash: string;
   bigBalanceText?: boolean;
@@ -31,6 +31,17 @@ const Balance: React.FC<Props> = ({
     });
   };
 
+  const PrettyBalance = () => {
+    const [integer, decimals] = assetBalance.split('.');
+    return (
+      <>
+        <span className="font-medium">{integer}.</span>
+        <span className="font-light">{decimals} </span>
+        <span className="font-medium">{assetTicker}</span>
+      </>
+    );
+  };
+
   return (
     <div className={className}>
       <AssetIcon
@@ -40,13 +51,13 @@ const Balance: React.FC<Props> = ({
       />
       <div>
         <p
-          className={cx('text-grayDark font-medium max-h-10', {
+          className={cx('text-grayDark max-h-10', {
             'text-3xl': bigBalanceText && !loading,
             'text-lg': !bigBalanceText && !loading,
             'animate-pulse': loading,
           })}
         >
-          {loading ? 'Loading...' : `${assetBalance} ${assetTicker}`}
+          {loading ? 'Loading...' : <PrettyBalance />}
         </p>
       </div>
     </div>
diff --git a/src/extension/utility/format.ts b/src/extension/utility/format.ts
index e615679..6470dc7 100644
--- a/src/extension/utility/format.ts
+++ b/src/extension/utility/format.ts
@@ -67,3 +67,36 @@ export const formatAssetName = (name?: string): string => {
 export const getMinAmountFromPrecision = (precision: number) => {
   return 1 * Math.pow(10, -precision);
 };
+
+// Converting to string with spaces every 3 digits
+// 0.12345678 => 0.12 345 678
+export const fromSatoshiWithSpaces = (
+  sats: number,
+  precision: number = defaultPrecision
+): string => {
+  if (sats === 0) return '0';
+
+  const clusterSize = 3;
+
+  const reverseString = (str: string) => str.split('').reverse().join('');
+
+  const str = reverseString(
+    fromSatoshi(sats, precision).toLocaleString('en-US', {
+      minimumFractionDigits: 8,
+      maximumFractionDigits: 8,
+    })
+  );
+
+  let start = 0;
+  let formated = '';
+
+  while (start < str.length) {
+    let finish = start + clusterSize > str.length ? str.length : start + clusterSize;
+    if (/\./.exec(str.slice(start, finish))) finish += 1;
+    formated += str.slice(start, finish);
+    if (finish < str.length) formated += ' ';
+    start = finish;
+  }
+
+  return reverseString(formated);
+};
diff --git a/src/extension/wallet/home/index.tsx b/src/extension/wallet/home/index.tsx
index a7a583c..a076f12 100644
--- a/src/extension/wallet/home/index.tsx
+++ b/src/extension/wallet/home/index.tsx
@@ -14,7 +14,7 @@ import ButtonAsset from '../../components/button-asset';
 import ButtonList from '../../components/button-list';
 import ShellPopUp from '../../components/shell-popup';
 import ButtonsSendReceive from '../../components/buttons-send-receive';
-import { fromSatoshiStr } from '../../utility';
+import { fromSatoshiWithSpaces } from '../../utility';
 import { SendFlowStep } from '../../../domain/repository';
 import type { Asset } from 'marina-provider';
 import { networks } from 'liquidjs-lib';
@@ -96,7 +96,7 @@ const Home: React.FC = () => {
             <Balance
               loading={cache?.balances.loading}
               assetHash={networks[cache?.network].assetHash}
-              assetBalance={fromSatoshiStr(
+              assetBalance={fromSatoshiWithSpaces(
                 cache?.balances.value[networks[cache?.network].assetHash] ?? 0
               )}
               assetTicker="L-BTC"
diff --git a/src/extension/wallet/send/address-amount.tsx b/src/extension/wallet/send/address-amount.tsx
index 127a8f9..dca8d5e 100644
--- a/src/extension/wallet/send/address-amount.tsx
+++ b/src/extension/wallet/send/address-amount.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { useHistory } from 'react-router';
 import Balance from '../../components/balance';
 import ShellPopUp from '../../components/shell-popup';
-import { fromSatoshi } from '../../utility';
+import { fromSatoshiWithSpaces } from '../../utility';
 import { DEFAULT_ROUTE } from '../../routes/constants';
 import AddressAmountForm from '../../components/address-amount-form';
 import type { Asset } from 'marina-provider';
@@ -46,7 +46,7 @@ const AddressAmountView: React.FC = () => {
         <>
           <Balance
             assetHash={sendAsset.assetHash}
-            assetBalance={fromSatoshi(
+            assetBalance={fromSatoshiWithSpaces(
               cache?.balances.value[sendAsset.assetHash] ?? 0,
               sendAsset.precision
             )}
diff --git a/src/extension/wallet/send/choose-fee.tsx b/src/extension/wallet/send/choose-fee.tsx
index 8d28ff4..95c58f9 100644
--- a/src/extension/wallet/send/choose-fee.tsx
+++ b/src/extension/wallet/send/choose-fee.tsx
@@ -4,7 +4,7 @@ import Balance from '../../components/balance';
 import Button from '../../components/button';
 import ShellPopUp from '../../components/shell-popup';
 import { SEND_ADDRESS_AMOUNT_ROUTE, SEND_CONFIRMATION_ROUTE } from '../../routes/constants';
-import { formatDecimalAmount, fromSatoshi, fromSatoshiStr } from '../../utility';
+import { fromSatoshiStr, fromSatoshiWithSpaces } from '../../utility';
 import useLottieLoader from '../../hooks/use-lottie-loader';
 import { extractErrorMessage } from '../../utility/error';
 import { networks } from 'liquidjs-lib';
@@ -136,8 +136,8 @@ const ChooseFee: React.FC = () => {
       currentPage="Send"
     >
       <Balance
-        assetBalance={formatDecimalAmount(
-          fromSatoshi(cache?.balances.value[selectedAssetHashWithLbtcFallback()] ?? 0)
+        assetBalance={fromSatoshiWithSpaces(
+          cache?.balances.value[selectedAssetHashWithLbtcFallback()] ?? 0
         )}
         assetHash={selectedAssetHashWithLbtcFallback()}
         assetTicker={assetDetails?.ticker ?? ''}
diff --git a/src/extension/wallet/transactions/index.tsx b/src/extension/wallet/transactions/index.tsx
index e33cdd9..b387a72 100644
--- a/src/extension/wallet/transactions/index.tsx
+++ b/src/extension/wallet/transactions/index.tsx
@@ -12,7 +12,7 @@ import ButtonList from '../../components/button-list';
 import ButtonsSendReceive from '../../components/buttons-send-receive';
 import ButtonTransaction from '../../components/button-transaction';
 import ShellPopUp from '../../components/shell-popup';
-import { fromSatoshiStr } from '../../utility';
+import { fromSatoshiWithSpaces } from '../../utility';
 import SaveMnemonicModal from '../../components/modal-save-mnemonic';
 import type { Asset } from 'marina-provider';
 import { useStorageContext } from '../../context/storage-context';
@@ -85,7 +85,10 @@ const Transactions: React.FC = () => {
         <>
           <Balance
             assetHash={assetHash}
-            assetBalance={fromSatoshiStr(cache?.balances.value[assetHash] ?? 0, asset?.precision)}
+            assetBalance={fromSatoshiWithSpaces(
+              cache?.balances.value[assetHash] ?? 0,
+              asset?.precision
+            )}
             assetTicker={asset?.ticker ?? assetHash.slice(0, 4)}
             bigBalanceText={true}
           />


Test patch:
diff --git a/playwright-tests/injected-script.spec.ts b/playwright-tests/injected-script.spec.ts
index 316c94c..06b9ad3 100644
--- a/playwright-tests/injected-script.spec.ts
+++ b/playwright-tests/injected-script.spec.ts
@@ -11,7 +11,7 @@ import {
   PASSWORD,
 } from './utils';
 import { faucet } from '../test/_regtest';
-import type { Artifact} from '@ionio-lang/ionio';
+import type { Artifact } from '@ionio-lang/ionio';
 import { Contract } from '@ionio-lang/ionio';
 import { AccountType } from 'marina-provider';
 import captchaArtifact from '../test/fixtures/customscript/transfer_with_captcha.ionio.json';
@@ -86,7 +86,7 @@ pwTest(
 
     const zkpLib = await require('@vulpemventures/secp256k1-zkp')();
 
-    const ecpair = ECPairFactory(ecc)
+    const ecpair = ECPairFactory(ecc);
     const keyPair = ecpair.makeRandom();
     const blindingKeyPair = ecpair.makeRandom();
 
@@ -97,7 +97,6 @@ pwTest(
       zkpLib
     );
 
-   
     const contractScript = contract.scriptPubKey.toString('hex');
     await pwExpect(
       provider.importScript('ionio', contractScript, blindingKeyPair.privateKey!.toString('hex'))
@@ -126,7 +125,7 @@ pwTest(
   'marina.signTransaction popup should display the correct amount of spent asset',
   async ({ page, extensionId, context }) => {
     await makeOnboardingRestore(page, extensionId);
-    await switchToRegtestNetwork(page, extensionId); 
+    await switchToRegtestNetwork(page, extensionId);
 
     await page.goto(vulpemFaucetURL);
     let provider = new PlaywrightMarinaProvider(page);
@@ -139,7 +138,7 @@ pwTest(
     if (!toFaucet.confidentialAddress) throw new Error('confidentialAddress is undefined');
     await faucet(toFaucet.confidentialAddress, 1); // send 1 L-BTC to the address
     await page.goto(marinaURL(extensionId, 'popup.html'));
-    await page.waitForSelector('text=1 L-BTC');
+    await page.waitForSelector('text=1.00 000 000 L-BTC');
 
     await page.goto(vulpemFaucetURL);
     provider = new PlaywrightMarinaProvider(page);
@@ -180,7 +179,7 @@ pwTest(
     const handleSignTransactionPopup = async () => {
       const popup = await context.waitForEvent('page');
       await popup.waitForSelector(`text= L-BTC`); // wait for loading to finish
-      const value = popup.getByTestId(networks.regtest.assetHash)
+      const value = popup.getByTestId(networks.regtest.assetHash);
       pwExpect(value).toBeTruthy();
       pwExpect(await value.innerText()).toEqual('0.999985');
       await popup.getByRole('button', { name: 'Reject' }).click();
@@ -199,7 +198,7 @@ pwTest(
   'marina.sendTransaction popup should display the correct amount of spent asset',
   async ({ page, extensionId, context }) => {
     await makeOnboardingRestore(page, extensionId);
-    await switchToRegtestNetwork(page, extensionId); 
+    await switchToRegtestNetwork(page, extensionId);
 
     await page.goto(vulpemFaucetURL);
     let provider = new PlaywrightMarinaProvider(page);
@@ -212,33 +211,36 @@ pwTest(
     if (!toFaucet.confidentialAddress) throw new Error('confidentialAddress is undefined');
     await faucet(toFaucet.confidentialAddress, 1); // send 1 L-BTC to the address
     await page.goto(marinaURL(extensionId, 'popup.html'));
-    await page.waitForSelector('text=1 L-BTC');
+    await page.waitForSelector('text=1.00 000 000 L-BTC');
 
     await page.goto(vulpemFaucetURL);
     provider = new PlaywrightMarinaProvider(page);
     // random regtest address
-    const receiver = 'el1qqdfjtdv5a7jez0qmj5g4u07pcg0qsm8jrwx0c6rza8kmquad2k62mwxw92f7vw0460wdx36m97er86rlkl3xsz774h2w3zpc9'
+    const receiver =
+      'el1qqdfjtdv5a7jez0qmj5g4u07pcg0qsm8jrwx0c6rza8kmquad2k62mwxw92f7vw0460wdx36m97er86rlkl3xsz774h2w3zpc9';
     const recipients = [
-      { 
+      {
         address: receiver,
         asset: networks.regtest.assetHash,
         value: 1_0000_0000 - 1500,
-      }
+      },
     ];
 
     const handleSendTransactionPopup = async () => {
       const popup = await context.waitForEvent('page');
       await popup.waitForSelector(`text= L-BTC`); // wait for loading to finish
-      const value = popup.getByTestId(networks.regtest.assetHash)
+      const value = popup.getByTestId(networks.regtest.assetHash);
       pwExpect(value).toBeTruthy();
       pwExpect(await value.innerText()).toEqual('0.999985');
-      const btn = popup.getByRole('button', { name: 'Reject' })
+      const btn = popup.getByRole('button', { name: 'Reject' });
       pwExpect(btn).toBeTruthy();
       await btn.click();
     };
 
     await Promise.all([
-      pwExpect(provider.sendTransaction(recipients)).rejects.toThrow('user rejected the sendTransaction request'),
+      pwExpect(provider.sendTransaction(recipients)).rejects.toThrow(
+        'user rejected the sendTransaction request'
+      ),
       handleSendTransactionPopup(),
     ]);
   }
diff --git a/playwright-tests/popup.spec.ts b/playwright-tests/popup.spec.ts
index 78a57b6..6c86454 100644
--- a/playwright-tests/popup.spec.ts
+++ b/playwright-tests/popup.spec.ts
@@ -29,18 +29,18 @@ pwTest(
     pwExpect(clipboard).toContain('el1');
     pwExpect(address.isConfidential(clipboard as string)).toBe(true);
 
-    // faucet 
+    // faucet
     const txid = await faucet(clipboard as string, 1); // send 1 L-BTC to the address
     await page.goto(marinaURL(extensionId, 'popup.html'));
     // wait to receive the funds
-    await page.waitForSelector('text=1 L-BTC');
+    await page.waitForSelector('text=1.00 000 000 L-BTC');
 
-    await page.getByRole('button', { name: 'Liquid Bitcoin'}).click(); // go to L-BTC page
+    await page.getByRole('button', { name: 'Liquid Bitcoin' }).click(); // go to L-BTC page
     // wait some time
     await page.getByRole('button', { name: '+1 L-BTC' }).click(); // click on tx
     await page.waitForSelector(`text=${txid}`); // check txid is displayed
     await page.waitForSelector('text=Inbound');
-    await page.waitForSelector('text=1 L-BTC'); // check amount is displayed
+    await page.waitForSelector('text=1.00 000 000 L-BTC'); // check amount is displayed
     await page.waitForSelector('text=Fee');
   }
 );
@@ -57,17 +57,21 @@ pwTest(
     await page.getByRole('button', { name: 'New Asset' }).click();
     await page.getByRole('button', { name: 'Copy' }).click();
     await page.waitForSelector('text=Copied');
-    const address = await page.evaluate("navigator.clipboard.readText()");
+    const address = await page.evaluate('navigator.clipboard.readText()');
     pwExpect(address as string).toContain('el1');
     await faucet(address as string, 1); // send 1 L-BTC to the address
     await page.goto(marinaURL(extensionId, 'popup.html'));
-    await page.waitForSelector('text=1 L-BTC');
+    await page.waitForSelector('text=1.00 000 000 L-BTC');
     await page.getByRole('button', { name: 'Send' }).click(); // go to send
     await page.getByText('Liquid Bitcoin').click(); // select L-BTC
-    await page.getByPlaceholder('el1...').fill('el1qq0vzd590j00zmmmnjajznkg52dw2st8drdnsxrh6gyd53g6yuf403fj26wlxtywylpxdx84vd67he6r059s0usrtlq73dyjpf'); // fill the address with a random regtest address
+    await page
+      .getByPlaceholder('el1...')
+      .fill(
+        'el1qq0vzd590j00zmmmnjajznkg52dw2st8drdnsxrh6gyd53g6yuf403fj26wlxtywylpxdx84vd67he6r059s0usrtlq73dyjpf'
+      ); // fill the address with a random regtest address
     await page.getByPlaceholder('0').fill('0.9'); // fill the amount
-    await page.getByRole('button', { name: 'Verify' }).click(); 
-    await page.getByRole('button', { name: 'L-BTC' }).click(); 
+    await page.getByRole('button', { name: 'Verify' }).click();
+    await page.getByRole('button', { name: 'L-BTC' }).click();
     await page.waitForSelector('text=Fee:');
     await page.getByRole('button', { name: 'Confirm' }).click(); // send
     await page.getByRole('button', { name: 'Send' }).click(); // confirm
diff --git a/test/application.spec.ts b/test/application.spec.ts
index 9e8443c..ba05df7 100644
--- a/test/application.spec.ts
+++ b/test/application.spec.ts
@@ -37,6 +37,7 @@ import {
 import { TaxiStorageAPI } from '../src/infrastructure/storage/taxi-repository';
 import { initWalletRepository } from '../src/domain/repository';
 import { computeBalances, lockTransactionInputs } from '../src/domain/transaction';
+import { fromSatoshiWithSpaces } from '../src/extension/utility/format';
 
 // we need this to mock the browser.storage.local calls in repositories
 // replace webextension-polyfill with a mock defined in __mocks__ folder
@@ -501,4 +502,36 @@ describe('Application Layer', () => {
       }, 10_000);
     });
   });
+
+  describe('Utils', () => {
+    describe('Balance is formated with spaces', () => {
+      test('1234567', () => {
+        const formated = fromSatoshiWithSpaces(1234567);
+        expect(formated).toBeDefined();
+        expect(formated).toEqual('0.01 234 567');
+      });
+      test('12345678', () => {
+        const formated = fromSatoshiWithSpaces(12345678);
+        expect(formated).toBeDefined();
+        expect(formated).toEqual('0.12 345 678');
+      });
+      test('123456789', () => {
+        const formated = fromSatoshiWithSpaces(123456789);
+        expect(formated).toBeDefined();
+        expect(formated).toEqual('1.23 456 789');
+      });
+
+      test('21000', () => {
+        const formated = fromSatoshiWithSpaces(21000);
+        expect(formated).toBeDefined();
+        expect(formated).toEqual('0.00 021 000');
+      });
+
+      test('0', () => {
+        const formated = fromSatoshiWithSpaces(0);
+        expect(formated).toBeDefined();
+        expect(formated).toEqual('0');
+      });
+    });
+  });
 });