POC for v4 token encoding and decoding

This commit is contained in:
minibits-cash
2024-07-26 22:34:19 +02:00
parent cb900e79a6
commit 9a57c63b6c
16 changed files with 264 additions and 78 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
"postinstall": "patch-package --exclude 'nothing'"
},
"dependencies": {
"@cashu/cashu-ts": "minibits-cash/cashu-ts#1.0.0-minibits.3",
"@cashu/cashu-ts": "minibits-cash/cashu-ts#1.0.0-minibits.4",
"@exodus/borc": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-regular-svg-icons": "^6.5.2",
+1 -1
View File
@@ -123,7 +123,7 @@ interface CardProps extends TouchableOpacityProps {
*
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Card.md)
*/
export function Card(props: CardProps) {
export const Card = function (props: CardProps) {
const backgroundColor = useThemeColor('card')
+2 -2
View File
@@ -1,5 +1,5 @@
import { verticalScale } from "@gocodingnow/rn-size-matters"
import React, { ReactElement } from "react"
import { ReactElement } from "react"
import { ColorValue, useColorScheme } from "react-native"
import {
StyleProp,
@@ -122,7 +122,7 @@ interface ListItemActionProps {
*
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-ListItem.md)
*/
export function ListItem(props: ListItemProps) {
export const ListItem = function (props: ListItemProps) {
const colorScheme = useColorScheme()
const {
+1 -1
View File
@@ -304,7 +304,7 @@
"profileToRecoverDesc": "This is the wallet address linked to your seed. If you press Complete, your wallet profile will reset to above seed and address.",
"pushNotifications": "Push notifications",
"qr": {
"fail": "Could not display QR code, copy or share the Ecash token instead."
"fail": "Could not display QR code, try new v4 token format or use option to send token to contact."
},
"receiverMemo": "Memo for the receiver",
"receiveScreen": {
+1
View File
@@ -335,6 +335,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<QRCodeBlock
qrCodeData={donationInvoice.payment_request}
title='Lightning invoice to pay'
type='Bolt11Invoice'
size={270}
/>
)}
+3 -6
View File
@@ -1,5 +1,5 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
import React, {FC, useState, useEffect} from 'react'
import {
TextStyle,
ViewStyle,
@@ -30,13 +30,10 @@ import AppError from '../utils/AppError'
import {BackupProof, Proof} from '../models/Proof'
import { useStores } from '../models'
import { CashuUtils } from '../services/cashu/cashuUtils'
import JSONTree from 'react-native-json-tree'
import Clipboard from '@react-native-clipboard/clipboard'
import { getEncodedToken } from '@cashu/cashu-ts'
import { Transaction, TransactionData, TransactionRecord, TransactionStatus, TransactionType } from '../models/Transaction'
import { WalletUtils } from '../services/wallet/utils'
import { MintUnit, MintUnits, getCurrency } from '../services/wallet/currency'
import { ScrollView } from 'react-native-gesture-handler'
import { MintUnits, getCurrency } from '../services/wallet/currency'
import { CurrencyAmount } from './Wallet/CurrencyAmount'
import { translate } from '../i18n'
@@ -172,7 +169,7 @@ export const LocalRecoveryScreen: FC<LocalRecoveryScreenProps> =
log.trace(tokenByMint)
const encodedByMint = getEncodedToken(tokenByMint)
const encodedByMint = CashuUtils.encodeToken(tokenByMint)
encodedTokens.push(encodedByMint)
}
}
@@ -112,6 +112,7 @@ export const OutgoingRequests = observer(function (props: {
<QRCodeBlock
qrCodeData={selectedRequest?.encodedInvoice as string}
title="Scan and pay to topup"
type='Bolt11Invoice'
size={270}
/>
}
+3 -2
View File
@@ -636,8 +636,9 @@ export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
{transactionStatus === TransactionStatus.PENDING && encodedTokenToSend && paymentOption && (
<>
<QRCodeBlock
qrCodeData={encodedTokenToSend as string}
title='Ecash token to send'
qrCodeData={encodedTokenToSend as string}
title='Ecash token to send'
type='EncodedV3Token'
/>
<TokenOptionsBlock
toggleNostrDMModal={toggleNostrDMModal}
-1
View File
@@ -22,7 +22,6 @@ import { Mint } from '../models/Mint'
import { MintHeader } from './Mints/MintHeader'
import { moderateVerticalScale } from '@gocodingnow/rn-size-matters'
import { translate } from '../i18n'
import { validCashuAToken } from '../services/cashu/cashuUtils'
export const TokenReceiveScreen: FC<WalletStackScreenProps<'TokenReceive'>> = function TokenReceiveScreen(_props) {
+2 -1
View File
@@ -752,7 +752,8 @@ export const TopupScreen: FC<WalletStackScreenProps<'Topup'>> = observer(
<>
<QRCodeBlock
qrCodeData={invoiceToPay as string}
title="Invoice to pay"
title='Invoice to pay'
type='Bolt11Invoice'
size={270}
/>
<InvoiceOptionsBlock
+1
View File
@@ -1284,6 +1284,7 @@ const TopupInfoBlock = function (props: {
<QRCodeBlock
qrCodeData={paymentRequest.encodedInvoice}
title={translate("tranDetailScreen.invoice")}
type='Bolt11Invoice'
size={spacing.screenWidth * 0.8}
/>
</View>
+47 -8
View File
@@ -7,16 +7,20 @@ import { moderateVerticalScale } from "@gocodingnow/rn-size-matters"
import { colors, spacing } from "../../theme"
import { useState } from "react"
import { translate } from "../../i18n"
import { CashuUtils } from "../../services/cashu/cashuUtils"
import { log } from "../../services"
export const QRCodeBlock = function (props: {
qrCodeData: string
qrCodeData: string
title: string
type: 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL'
size?: number
}
) {
const {qrCodeData, title, size} = props
const [qrError, setQrError] = useState<Error | undefined>()
const {qrCodeData, title, type, size} = props
const [qrError, setQrError] = useState<Error | undefined>()
const [encodedV4Token, setEncodedV4Token] = useState<string | undefined>()
const handleQrError = function (error: Error) {
setQrError(error)
@@ -40,6 +44,26 @@ export const QRCodeBlock = function (props: {
setQrError(e)
}
}
const switchTokenEncoding = function () {
try {
if(encodedV4Token) {
setEncodedV4Token(undefined)
} else if(type === 'EncodedV3Token') {
log.trace('[v3]', qrCodeData)
const decoded = CashuUtils.decodeToken(qrCodeData)
const encodedV4 = CashuUtils.encodeToken(decoded, 4)
log.trace('[v4]', encodedV4)
setEncodedV4Token(encodedV4)
}
} catch (e: any) {
setQrError(e)
}
}
const onCopy = function () {
@@ -54,7 +78,7 @@ export const QRCodeBlock = function (props: {
<Card
heading={title}
headingStyle={{textAlign: 'center', color: colors.light.text, marginBottom: spacing.extraSmall}}
style={{backgroundColor: 'white', paddingBottom: 0}}
style={{backgroundColor: 'white', paddingBottom: spacing.small}}
ContentComponent={qrError ? (
<ListItem
text={translate("qr.fail")}
@@ -67,11 +91,11 @@ export const QRCodeBlock = function (props: {
) : (
<View style={$qrCodeContainer}>
<QRCode
size={size || spacing.screenWidth - spacing.large * 2} value={qrCodeData}
size={size || spacing.screenWidth - spacing.large * 2} value={encodedV4Token || qrCodeData}
onError={(error: any) => handleQrError(error)}
/>
</View>
)}
)}
FooterComponent={
<View style={$buttonContainer}>
<Button
@@ -99,8 +123,23 @@ export const QRCodeBlock = function (props: {
paddingVertical: moderateVerticalScale(spacing.tiny),
marginRight: spacing.small
}}
/>
</View>
/>
{type === 'EncodedV3Token' && (
<Button
preset="tertiary"
text={`Show ${encodedV4Token ? 'v3' : 'v4'} format`}
onPress={switchTokenEncoding}
LeftAccessory={() => <Icon icon='faMoneyBill1' size={spacing.small} color={colors.light.text} />}
textStyle={{color: colors.light.text, fontSize: 14}}
style={{
minWidth: 60,
minHeight: moderateVerticalScale(40),
paddingVertical: moderateVerticalScale(spacing.tiny),
marginRight: spacing.small
}}
/>
)}
</View>
}
/>
+173 -46
View File
@@ -1,20 +1,19 @@
import {Mint} from '../../models/Mint'
import {
AmountPreference,
getDecodedToken,
getDecodedToken,
getEncodedToken,
} from '@cashu/cashu-ts'
import cloneDeep from 'lodash.clonedeep'
import cbor from '@exodus/borc'
import type {Token as V3Token, TokenEntry as V3TokenEntry, Proof as V3Proof} from '@cashu/cashu-ts'
import AppError, {Err} from '../../utils/AppError'
import {Token} from '../../models/Token'
import {TokenEntry} from '../../models/TokenEntry'
import {} from '@cashu/cashu-ts'
import {Proof} from '../../models/Proof'
import { log } from '../logService'
// import { encodeCBOR } from '@cashu/cashu-ts/src/cbor'
import { encodeBase64ToJson, encodeBase64toUint8, encodeUint8toBase64 } from '@cashu/cashu-ts/src/base64'
import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils';
const findEncodedCashuToken = function (content: string) {
const words = content.split(/\s+|\n+/) // Split text into words
const maybeToken = words.find(word => CASHU_TOKEN_PREFIXES.some(pref => word.includes(pref)))
return maybeToken || null
}
const CASHU_URI_PREFIXES = [
'https://wallet.nutstash.app/#',
@@ -28,8 +27,13 @@ const CASHU_TOKEN_PREFIXES = [
'cashuB'
]
const findEncodedCashuToken = function (content: string) {
const words = content.split(/\s+|\n+/) // Split text into words
const maybeToken = words.find(word => CASHU_TOKEN_PREFIXES.some(pref => word.includes(pref)))
return maybeToken || null
}
export function validCashuAToken(text: string) {
const isValidCashuToken = function (text: string) {
for (const prefix of CASHU_URI_PREFIXES) {
if (text && text.startsWith(prefix)) {
text = text.slice(prefix.length)
@@ -44,19 +48,19 @@ const extractEncodedCashuToken = function (maybeToken: string): string {
log.trace('[extractEncodedCashuToken] Extract token from', {maybeToken})
let encodedToken: string | undefined = undefined
let decoded: Token | undefined = undefined
let decoded: V3Token | undefined = undefined
if (maybeToken && CASHU_TOKEN_PREFIXES.some(pref => maybeToken.startsWith(pref))) {
decoded = decodeToken(maybeToken) // throws
return maybeToken
}
for (const prefix of CASHU_URI_PREFIXES) {
if (maybeToken && maybeToken.startsWith(prefix)) {
encodedToken = maybeToken.slice(prefix.length)
break // necessary
}
}
for (const prefix of CASHU_URI_PREFIXES) {
if (maybeToken && maybeToken.startsWith(prefix)) {
encodedToken = maybeToken.slice(prefix.length)
break // necessary
}
}
log.trace('[extractEncodedCashuToken] Token without prefix', {encodedToken})
@@ -71,22 +75,162 @@ const extractEncodedCashuToken = function (maybeToken: string): string {
const decodeToken = function (encoded: string): Token {
/* const decodeToken = function (encoded: string): V3Token {
try {
const decoded = getDecodedToken(encoded)
return decoded as Token
return decoded as V3Token
} catch (e: any) {
throw new AppError(
Err.VALIDATION_ERROR,
`Provided ecash token is invalid.`,
encoded,
{message: e.message, encoded},
)
}
} */
interface V4Proof {
a: number;
s: string;
c: Uint8Array;
d?: {
e: Uint8Array,
s: Uint8Array,
r: Uint8Array
},
w?: string
}
interface V4TokenEntry {
i: Uint8Array;
p: V4Proof[];
}
interface V4Token {
m: string;
u: string;
d?: string;
t: V4TokenEntry[]
}
function mapToV4TokenEntries(tokenEntries: V3TokenEntry[]): V4TokenEntry[] {
const v4TokenEntries: V4TokenEntry[] = [];
tokenEntries.forEach(entry => {
const idMap: { [id: string]: V4Proof[] } = {};
entry.proofs.forEach(proof => {
if (!idMap[proof.id]) {
idMap[proof.id] = [];
}
idMap[proof.id].push({
a: proof.amount,
s: proof.secret,
c: hexToBytes(proof.C)
});
});
for (const id in idMap) {
v4TokenEntries.push({
i: hexToBytes(id),
p: idMap[id]
});
}
});
return v4TokenEntries
}
function base64urlFromBase64(str: string) {
return str.replace(/\+/g, '-').replace(/\//g, '_').split('=')[0];
// .replace(/=/g, '.');
}
function base64urlToBase64(str: string) {
return str.replace(/-/g, '+').replace(/_/g, '/').split('=')[0];
// .replace(/./g, '=');
}
const encodeToken = function (token: V3Token, version: 3 | 4 = 3): string {
try {
if(version === 3) {
return getEncodedToken(token)
} else if(version === 4) {
const v4tokenEntries = mapToV4TokenEntries(token.token)
const v4Token: V4Token = {
m: token.token[0].mint as string,
u: token.unit as string,
d: token.memo as string,
t: v4tokenEntries
}
log.trace('[encodeToken]', {v4Token})
const encodedCbor = cbor.encode(v4Token)
return 'cashuB' + base64urlFromBase64(encodeUint8toBase64(encodedCbor))
} else {
throw new Error('Invalid version.')
}
} catch (e: any) {
throw new AppError(
Err.VALIDATION_ERROR,
`Error encoding token to version {${version}} format.`,
{message: e.message},
)
}
}
/**
* Helper function to decode cashu tokens into object
* @param token an encoded cashu token (cashuAey...)
* @returns cashu token object
*/
function decodeToken(token: string) {
// remove prefixes
const uriPrefixes = [...CASHU_URI_PREFIXES, 'cashu'];
uriPrefixes.forEach((prefix) => {
if (!token.startsWith(prefix)) {
return;
}
token = token.slice(prefix.length);
});
return handleTokens(token);
}
const getTokenAmounts = function (token: Token) {
/**
* @param token
* @returns
*/
function handleTokens(token: string): V3Token {
const version = token.slice(0, 1);
const encodedToken = token.slice(1);
if (version === 'A') {
return encodeBase64ToJson<V3Token>(encodedToken);
} else if (version === 'B') {
const uInt8Token = encodeBase64toUint8(base64urlToBase64(encodedToken));
const tokenData = cbor.decodeFirst(uInt8Token) as V4Token
const mergedTokenEntry: V3TokenEntry = { mint: tokenData.m, proofs: [] };
tokenData.t.forEach((tokenEntry) =>
tokenEntry.p.forEach((p) => {
mergedTokenEntry.proofs.push({
secret: p.s,
C: bytesToHex(p.c),
amount: p.a,
id: bytesToHex(tokenEntry.i)
});
})
);
return { token: [mergedTokenEntry], memo: tokenData.d || '', unit: tokenData.u };
} else {
throw new Error('Token version is not supported');
}
}
const getTokenAmounts = function (token: V3Token) {
const mintAmounts: {[k: string]: number} = {}
let totalAmount = 0
@@ -118,7 +262,7 @@ const getTokenAmounts = function (token: Token) {
}
const getTokenEntryAmount = function (tokenEntry: TokenEntry) {
const getTokenEntryAmount = function (tokenEntry: V3TokenEntry) {
try {
return getProofsAmount(tokenEntry.proofs)
} catch (e: any) {
@@ -131,7 +275,7 @@ const getTokenEntryAmount = function (tokenEntry: TokenEntry) {
}
const getProofsAmount = function (proofs: Array<Proof>): number {
const getProofsAmount = function (proofs: Array<V3Proof>): number {
let totalAmount = 0
for (const proof of proofs) {
@@ -148,14 +292,14 @@ const getAmountPreferencesCount = function (amountPreferences: AmountPreference[
}
const getMintsFromToken = function (token: Token): string[] {
const getMintsFromToken = function (token: V3Token): string[] {
const mints = token.token.map(item => item.mint)
return Array.from(new Set(mints)) // make sure the mints are not duplicated
}
const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => {
const proofs: Proof[] = []
const getProofsFromTokenEntries = (tokenEntries: V3TokenEntry[]) => {
const proofs: V3Proof[] = []
for (const entry of tokenEntries) {
proofs.push(...entry.proofs)
@@ -221,25 +365,6 @@ const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Pro
return findMinExcess(requestedAmount, proofs);
}
/**
* returns a subset of tokens, so that not all tokens are sent to mint for smaller amounts.
* @param amount
* @param tokens
* @returns
*/
/* const getProofsToSend = function (amount: number, proofs: Array<Proof>) {
let proofsAmount = 0
const proofSubset = proofs.filter(proof => {
if (proofsAmount < amount) {
proofsAmount += proof.amount
return true
}
})
return proofSubset
} */
/**
* removes a set of tokens from another set of tokens, and returns the remaining.
* @param proofs
@@ -306,7 +431,9 @@ const getMintFromProof = function (
export const CashuUtils = {
findEncodedCashuToken,
extractEncodedCashuToken,
decodeToken,
isValidCashuToken,
decodeToken,
encodeToken,
getTokenAmounts,
getTokenEntryAmount,
getProofsAmount,
+3 -3
View File
@@ -16,7 +16,7 @@ import {
type Token as CashuToken,
type Proof as CashuProof
} from '@cashu/cashu-ts'
import { getDefaultAmountPreference, getEncodedToken } from '@cashu/cashu-ts/src/utils'
import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils'
import { TransactionTaskResult } from '../walletService'
import { WalletUtils } from './utils'
import { MintUnit, formatCurrency, getCurrency } from './currency'
@@ -163,7 +163,7 @@ export const receiveTask = async function (
proofs: receivedProofs,
}
const updatedToken = getEncodedToken({
const updatedToken = CashuUtils.encodeToken({
token: [tokenEntryToSend],
unit,
memo,
@@ -491,7 +491,7 @@ export const receiveOfflineCompleteTask = async function (
proofs: receivedProofs,
}
const updatedToken = getEncodedToken({
const updatedToken = CashuUtils.encodeToken({
token: [tokenEntryToSend],
unit,
})
+1 -2
View File
@@ -1,4 +1,3 @@
import {getEncodedToken} from '@cashu/cashu-ts'
import {log} from '../logService'
import {MintClient} from '../cashuMintClient'
import {
@@ -108,7 +107,7 @@ export const sendTask = async function (
memo = 'Sent from Minibits'
}
const encodedTokenToSend = getEncodedToken({
const encodedTokenToSend = CashuUtils.encodeToken({
token: [tokenEntryToSend as CashuTokenEntry],
unit,
memo,
+23 -4
View File
@@ -1202,9 +1202,9 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@cashu/cashu-ts@minibits-cash/cashu-ts#1.0.0-minibits.2":
version "1.0.0-minibits"
resolved "https://codeload.github.com/minibits-cash/cashu-ts/tar.gz/03b26c1db3a72d3d8e975a72f63b0ed06dc1be34"
"@cashu/cashu-ts@minibits-cash/cashu-ts#1.0.0-minibits.4":
version "1.0.0-minibits.4"
resolved "https://codeload.github.com/minibits-cash/cashu-ts/tar.gz/c8932a044126e143a5460c14a3a47c196e26baa2"
dependencies:
"@cashu/crypto" "^0.2.6"
"@noble/curves" "^1.3.0"
@@ -1271,6 +1271,15 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.55.0.tgz#b721d52060f369aa259cf97392403cb9ce892ec6"
integrity sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==
"@exodus/borc@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@exodus/borc/-/borc-2.1.1.tgz#42f38d3b96bcf53a93c72e9739192b876a8058fa"
integrity sha512-rOFa4apJXK67mdUU8RI25umOIXUEpt+gwqPKsNCg/OwTYhi2b1tzX6H+g8dXahHzG7gwOImoDQmw8Vxho+us5A==
dependencies:
bignumber.js "^8.0.1"
ieee754 "^1.1.8"
iso-url "~0.4.4"
"@fortawesome/fontawesome-common-types@6.5.2":
version "6.5.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz#eaf2f5699f73cef198454ebc0c414e3688898179"
@@ -3048,6 +3057,11 @@ bignumber.js@*, "bignumber.js@^8 || ^9":
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c"
integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==
bignumber.js@^8.0.1:
version "8.1.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-8.1.1.tgz#4b072ae5aea9c20f6730e4e5d529df1271c4d885"
integrity sha512-QD46ppGintwPGuL1KqmwhR0O+N2cZUg8JG/VzwI2e28sM9TqHjQB10lI4QAaMHVbLzwVLLAwEglpKPViWX+5NQ==
bl@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
@@ -4957,7 +4971,7 @@ iconv-lite@^0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
ieee754@^1.1.13, ieee754@^1.2.1:
ieee754@^1.1.13, ieee754@^1.1.8, ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@@ -5321,6 +5335,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
iso-url@~0.4.4:
version "0.4.7"
resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.7.tgz#de7e48120dae46921079fe78f325ac9e9217a385"
integrity sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog==
isobject@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"