Skip to content

Commit

Permalink
Support bech32 address standard (#2351)
Browse files Browse the repository at this point in the history
* create settings context with simple address format toggler

* transform base16 hash to bech32 hash

* add ENV variables

* add snippet to address page

* add redirect for bech32 addresses

* change address format in search

* add provider to tests

* update demo values

* migrate from Buffer to Uint8Array and add tests

* bug fixes and screenshots updates

* review fixes

* roll back changes in env values

* update screenshots
  • Loading branch information
tom2drum authored Nov 4, 2024
1 parent e0b89d0 commit 8c6d740
Show file tree
Hide file tree
Showing 66 changed files with 509 additions and 131 deletions.
30 changes: 28 additions & 2 deletions configs/app/ui/views/address.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SmartContractVerificationMethodExtra } from 'types/client/contract';
import { SMART_CONTRACT_EXTRA_VERIFICATION_METHODS } from 'types/client/contract';
import type { AddressViewId, IdenticonType } from 'types/views/address';
import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from 'types/views/address';
import type { AddressFormat, AddressViewId, IdenticonType } from 'types/views/address';
import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from 'types/views/address';

import { getEnvValue, parseEnvJson } from 'configs/app/utils';

Expand All @@ -11,6 +11,28 @@ const identiconType: IdenticonType = (() => {
return IDENTICON_TYPES.find((type) => value === type) || 'jazzicon';
})();

const formats: Array<AddressFormat> = (() => {
const value = (parseEnvJson<Array<AddressFormat>>(getEnvValue('NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT')) || [])
.filter((format) => ADDRESS_FORMATS.includes(format));

if (value.length === 0) {
return [ 'base16' ];
}

return value;
})();

const bech32Prefix = (() => {
const value = getEnvValue('NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX');

if (!value || !formats.includes('bech32')) {
return undefined;
}

// these are the limits of the bech32 prefix - https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
return value.length >= 1 && value.length <= 83 ? value : undefined;
})();

const hiddenViews = (() => {
const parsedValue = parseEnvJson<Array<AddressViewId>>(getEnvValue('NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS')) || [];

Expand Down Expand Up @@ -43,6 +65,10 @@ const extraVerificationMethods: Array<SmartContractVerificationMethodExtra> = ((

const config = Object.freeze({
identiconType,
hashFormat: {
availableFormats: formats,
bech32Prefix,
},
hiddenViews,
solidityscanEnabled: getEnvValue('NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED') === 'true',
extraVerificationMethods,
Expand Down
2 changes: 2 additions & 0 deletions configs/envs/.env.pw
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom
17 changes: 15 additions & 2 deletions deploy/tools/envs-validator/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeSta
import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks';
import { COLOR_THEME_IDS } from '../../../types/settings';
import type { FontFamily } from '../../../types/ui';
import type { AddressViewId } from '../../../types/views/address';
import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address';
import type { AddressFormat, AddressViewId } from '../../../types/views/address';
import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address';
import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block';
import type { NftMarketplaceItem } from '../../../types/views/nft';
Expand Down Expand Up @@ -658,6 +658,19 @@ const schema = yup
.json()
.of(yup.string<BlockFieldId>().oneOf(BLOCK_FIELDS_IDS)),
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: yup.string().oneOf(IDENTICON_TYPES),
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT: yup
.array()
.transform(replaceQuotes)
.json()
.of(yup.string<AddressFormat>().oneOf(ADDRESS_FORMATS)),
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX: yup
.string()
.when('NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT', {
is: (value: Array<AddressFormat> | undefined) => value && value.includes('bech32'),
then: (schema) => schema.required().min(1).max(83),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX is required if NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT contains "bech32"'),
}),

NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS: yup
.array()
.transform(replaceQuotes)
Expand Down
4 changes: 3 additions & 1 deletion deploy/tools/envs-validator/test/.env.alt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=none
NEXT_PUBLIC_API_SPEC_URL=none
NEXT_PUBLIC_VIEWS_CONTRACT_EXTRA_VERIFICATION_METHODS=none
NEXT_PUBLIC_HOMEPAGE_STATS=[]
NEXT_PUBLIC_HOMEPAGE_STATS=[]
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=foo
1 change: 1 addition & 0 deletions deploy/tools/envs-validator/test/.env.base
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://example.com
NEXT_PUBLIC_STATS_API_BASE_PATH=/
NEXT_PUBLIC_USE_NEXT_JS_PROXY=false
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=gradient_avatar
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16']
NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS=['top_accounts']
NEXT_PUBLIC_VIEWS_CONTRACT_EXTRA_VERIFICATION_METHODS=['solidity-hardhat','solidity-foundry']
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward']
Expand Down
2 changes: 2 additions & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ Settings for meta tags, OG tags and SEO
| Variable | Type | Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Default style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` | v1.12.0+ |
| NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT | `Array<"base16" \| "bech32">` | Displayed address format, could be either `base16` standard or [`bech32`](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32) standard. If the array contains multiple values, the address format toggle will appear in the UI, allowing the user to switch between formats. The first item in the array will be the default format. | - | `'["base16"]'` | `'["bech32", "base16"]'` | v1.36.0+ |
| NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX | `string` | Human-readable prefix of `bech32` address format. | Required, if `NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT` contains "bech32" value | - | `duck` | v1.36.0+ |
| NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS | `Array<AddressViewId>` | Address views that should not be displayed. See below the list of the possible id values. | - | - | `'["top_accounts"]'` | v1.15.0+ |
| NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED | `boolean` | Set to `true` if SolidityScan reports are supported | - | - | `true` | v1.19.0+ |
| NEXT_PUBLIC_VIEWS_CONTRACT_EXTRA_VERIFICATION_METHODS | `Array<'solidity-hardhat' \| 'solidity-foundry'>` | Pass an array of additional methods from which users can choose while verifying a smart contract. Both methods are available by default, pass `'none'` string to disable them all. | - | - | `['solidity-hardhat']` | v1.33.0+ |
Expand Down
49 changes: 49 additions & 0 deletions lib/address/bech32.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { bech32 } from '@scure/base';

import config from 'configs/app';
import bytesToHex from 'lib/bytesToHex';
import hexToBytes from 'lib/hexToBytes';

export const DATA_PART_REGEXP = /^[\da-z]{38}$/;
export const BECH_32_SEPARATOR = '1'; // https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32

export function toBech32Address(hash: string) {
if (config.UI.views.address.hashFormat.bech32Prefix) {
try {
const words = bech32.toWords(hexToBytes(hash));
return bech32.encode(config.UI.views.address.hashFormat.bech32Prefix, words);
} catch (error) {}
}

return hash;
}

export function isBech32Address(hash: string) {
if (!config.UI.views.address.hashFormat.bech32Prefix) {
return false;
}

if (!hash.startsWith(`${ config.UI.views.address.hashFormat.bech32Prefix }${ BECH_32_SEPARATOR }`)) {
return false;
}

const strippedHash = hash.replace(`${ config.UI.views.address.hashFormat.bech32Prefix }${ BECH_32_SEPARATOR }`, '');
return DATA_PART_REGEXP.test(strippedHash);
}

export function fromBech32Address(hash: string) {
if (config.UI.views.address.hashFormat.bech32Prefix) {
try {
const { words, prefix } = bech32.decode(hash as `${ string }${ typeof BECH_32_SEPARATOR }${ string }`);

if (prefix !== config.UI.views.address.hashFormat.bech32Prefix) {
return hash;
}

const bytes = bech32.fromWords(words);
return bytesToHex(bytes);
} catch (error) {}
}

return hash;
}
2 changes: 1 addition & 1 deletion lib/blob/guessDataType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import hexToBytes from 'lib/hexToBytes';
import removeNonSignificantZeroBytes from './removeNonSignificantZeroBytes';

export default function guessDataType(data: string) {
const bytes = new Uint8Array(hexToBytes(data));
const bytes = hexToBytes(data);
const filteredBytes = removeNonSignificantZeroBytes(bytes);

return filetype(filteredBytes)[0];
Expand Down
8 changes: 8 additions & 0 deletions lib/bytesToHex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function bytesToBase64(bytes: Uint8Array) {
let result = '';
for (const byte of bytes) {
result += Number(byte).toString(16).padStart(2, '0');
}

return `0x${ result }`;
}
56 changes: 56 additions & 0 deletions lib/contexts/settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

import { ADDRESS_FORMATS, type AddressFormat } from 'types/views/address';

import * as cookies from 'lib/cookies';

import { useAppContext } from './app';

interface SettingsProviderProps {
children: React.ReactNode;
}

interface TSettingsContext {
addressFormat: AddressFormat;
toggleAddressFormat: () => void;
}

export const SettingsContext = React.createContext<TSettingsContext | null>(null);

export function SettingsContextProvider({ children }: SettingsProviderProps) {
const { cookies: appCookies } = useAppContext();
const initialAddressFormat = cookies.get(cookies.NAMES.ADDRESS_FORMAT, appCookies);

const [ addressFormat, setAddressFormat ] = React.useState<AddressFormat>(
initialAddressFormat && ADDRESS_FORMATS.includes(initialAddressFormat) ? initialAddressFormat as AddressFormat : 'base16',
);

const toggleAddressFormat = React.useCallback(() => {
setAddressFormat(prev => {
const nextValue = prev === 'base16' ? 'bech32' : 'base16';
cookies.set(cookies.NAMES.ADDRESS_FORMAT, nextValue);
return nextValue;
});
}, []);

const value = React.useMemo(() => {
return {
addressFormat,
toggleAddressFormat,
};
}, [ addressFormat, toggleAddressFormat ]);

return (
<SettingsContext.Provider value={ value }>
{ children }
</SettingsContext.Provider>
);
}

export function useSettingsContext(disabled?: boolean) {
const context = React.useContext(SettingsContext);
if (context === undefined || disabled) {
return null;
}
return context;
}
1 change: 1 addition & 0 deletions lib/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum NAMES {
COLOR_MODE='chakra-ui-color-mode',
COLOR_MODE_HEX='chakra-ui-color-mode-hex',
ADDRESS_IDENTICON_TYPE='address_identicon_type',
ADDRESS_FORMAT='address_format',
INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug',
Expand Down
2 changes: 1 addition & 1 deletion lib/hexToBase64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import bytesToBase64 from './bytesToBase64';
import hexToBytes from './hexToBytes';

export default function hexToBase64(hex: string) {
const bytes = new Uint8Array(hexToBytes(hex));
const bytes = hexToBytes(hex);

return bytesToBase64(bytes);
}
2 changes: 1 addition & 1 deletion lib/hexToBytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export default function hexToBytes(hex: string) {
for (let c = startIndex; c < hex.length; c += 2) {
bytes.push(parseInt(hex.substring(c, c + 2), 16));
}
return bytes;
return new Uint8Array(bytes);
}
2 changes: 1 addition & 1 deletion lib/hexToUtf8.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import hexToBytes from 'lib/hexToBytes';

export default function hexToUtf8(hex: string) {
const utf8decoder = new TextDecoder();
const bytes = new Uint8Array(hexToBytes(hex));
const bytes = hexToBytes(hex);

return utf8decoder.decode(bytes);
}
1 change: 1 addition & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function middleware(req: NextRequest) {
const res = NextResponse.next();

middlewares.colorTheme(req, res);
middlewares.addressFormat(req, res);

const end = Date.now();

Expand Down
20 changes: 20 additions & 0 deletions nextjs/middlewares/addressFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { NextRequest, NextResponse } from 'next/server';

import type { AddressFormat } from 'types/views/address';

import config from 'configs/app';
import * as cookiesLib from 'lib/cookies';

export default function addressFormatMiddleware(req: NextRequest, res: NextResponse) {
const addressFormatCookie = req.cookies.get(cookiesLib.NAMES.ADDRESS_FORMAT);
const defaultFormat = config.UI.views.address.hashFormat.availableFormats[0];

if (addressFormatCookie) {
const isValidCookie = config.UI.views.address.hashFormat.availableFormats.includes(addressFormatCookie.value as AddressFormat);
if (!isValidCookie) {
res.cookies.set(cookiesLib.NAMES.ADDRESS_FORMAT, defaultFormat, { path: '/' });
}
} else {
res.cookies.set(cookiesLib.NAMES.ADDRESS_FORMAT, defaultFormat, { path: '/' });
}
}
1 change: 1 addition & 0 deletions nextjs/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { account } from './account';
export { default as colorTheme } from './colorTheme';
export { default as addressFormat } from './addressFormat';
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@opentelemetry/sdk-node": "0.49.1",
"@opentelemetry/sdk-trace-node": "1.22.0",
"@opentelemetry/semantic-conventions": "1.22.0",
"@scure/base": "1.1.9",
"@sentry/cli": "^2.21.2",
"@sentry/react": "7.24.0",
"@sentry/tracing": "7.24.0",
Expand Down
7 changes: 5 additions & 2 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ChakraProvider } from 'lib/contexts/chakra';
import { MarketplaceContextProvider } from 'lib/contexts/marketplace';
import { RewardsContextProvider } from 'lib/contexts/rewards';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import { SettingsContextProvider } from 'lib/contexts/settings';
import { growthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import useNotifyOnNavigation from 'lib/hooks/useNotifyOnNavigation';
Expand Down Expand Up @@ -73,8 +74,10 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }>
<RewardsContextProvider>
<MarketplaceContextProvider>
{ getLayout(<Component { ...pageProps }/>) }
{ config.features.rewards.isEnabled && <RewardsLoginModal/> }
<SettingsContextProvider>
{ getLayout(<Component { ...pageProps }/>) }
{ config.features.rewards.isEnabled && <RewardsLoginModal/> }
</SettingsContextProvider>
</MarketplaceContextProvider>
</RewardsContextProvider>
</SocketProvider>
Expand Down
17 changes: 10 additions & 7 deletions playwright/TestApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { MarketplaceContext } from 'lib/contexts/marketplace';
import { RewardsContextProvider } from 'lib/contexts/rewards';
import { SettingsContextProvider } from 'lib/contexts/settings';
import { SocketProvider } from 'lib/socket/context';
import currentChain from 'lib/web3/currentChain';
import theme from 'theme/theme';
Expand Down Expand Up @@ -76,13 +77,15 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<MarketplaceContext.Provider value={ marketplaceContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
<RewardsContextProvider>
{ children }
</RewardsContextProvider>
</WagmiProvider>
</GrowthBookProvider>
<SettingsContextProvider>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
<RewardsContextProvider>
{ children }
</RewardsContextProvider>
</WagmiProvider>
</GrowthBookProvider>
</SettingsContextProvider>
</MarketplaceContext.Provider>
</AppContextProvider>
</SocketProvider>
Expand Down
4 changes: 4 additions & 0 deletions playwright/fixtures/mockEnvs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
rewardsService: [
[ 'NEXT_PUBLIC_REWARDS_SERVICE_API_HOST', 'http://localhost:3003' ],
],
addressBech32Format: [
[ 'NEXT_PUBLIC_ADDRESS_FORMAT', '["bech32","base16"]' ],
[ 'NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX', 'tom' ],
],
};
3 changes: 3 additions & 0 deletions types/views/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ export const ADDRESS_VIEWS_IDS = [
] as const;

export type AddressViewId = ArrayElement<typeof ADDRESS_VIEWS_IDS>;

export const ADDRESS_FORMATS = [ 'base16', 'bech32' ] as const;
export type AddressFormat = typeof ADDRESS_FORMATS[ number ];
3 changes: 3 additions & 0 deletions ui/address/AddressDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';

import AddressAlternativeFormat from './details/AddressAlternativeFormat';
import AddressBalance from './details/AddressBalance';
import AddressImplementations from './details/AddressImplementations';
import AddressNameInfo from './details/AddressNameInfo';
Expand Down Expand Up @@ -98,6 +99,8 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} overflow="hidden"
>
<AddressAlternativeFormat isLoading={ addressQuery.isPlaceholderData } addressHash={ addressHash }/>

{ data.filecoin?.id && (
<>
<DetailsInfoItem.Label
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 8c6d740

Please sign in to comment.