From 53bc95f982787e379666739461354716f3119956 Mon Sep 17 00:00:00 2001 From: Cordt Date: Mon, 26 Aug 2024 23:30:56 -0600 Subject: [PATCH 1/3] Add wallet utility functions and test --- packages/evm/README.md | 50 ++++++++++++++++++ packages/evm/package.json | 13 ++++- packages/evm/src/index.ts | 1 + packages/evm/src/utils/index.ts | 1 + packages/evm/src/utils/walletUtils.spec.ts | 59 ++++++++++++++++++++++ packages/evm/src/utils/walletUtils.ts | 52 +++++++++++++++++++ packages/evm/tsconfig.json | 4 +- 7 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 packages/evm/src/utils/index.ts create mode 100644 packages/evm/src/utils/walletUtils.spec.ts create mode 100644 packages/evm/src/utils/walletUtils.ts diff --git a/packages/evm/README.md b/packages/evm/README.md index 08b8d421..59602c8e 100644 --- a/packages/evm/README.md +++ b/packages/evm/README.md @@ -77,6 +77,56 @@ const amount = parseSei('1000000'); console.log(amount); // 1000000000000000000 ``` +### Wallet Utilities + +The package provides a set of wallet utilities for generating and validating Sei and EVM addresses. + +#### `generateAddressesFromPrivateKey` +Generates both Sei and EVM addresses from a given private key. + +```ts +import { generateAddressesFromPrivateKey } from '@sei-js/evm'; + +const privateKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; +const { seiAddress, evmAddress } = generateAddressesFromPrivateKey(privateKey); + +console.log('Sei Address:', seiAddress); +console.log('EVM Address:', evmAddress); +``` + +#### `deriveAddressesFromPublicKey` +Derives both Sei and EVM addresses from a given public key. + +```ts +import { deriveAddressesFromPublicKey } from '@sei-js/evm'; + +const publicKey = new Uint8Array([/* ... */]); // Compressed public key +const { seiAddress, evmAddress } = deriveAddressesFromPublicKey(publicKey); + +console.log('Sei Address:', seiAddress); +console.log('EVM Address:', evmAddress); +``` + +#### `isValidSeiAddress` +Validates a Sei address. + +```ts +import { isValidSeiAddress } from '@sei-js/evm'; + +const isValid = isValidSeiAddress('sei1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwp'); +console.log('Is valid Sei address:', isValid); +``` + +#### `isValidEvmAddress` +Validates an EVM address. + +```ts +import { isValidEvmAddress } from '@sei-js/evm'; + +const isValid = isValidEvmAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44e'); +console.log('Is valid EVM address:', isValid); +``` +

diff --git a/packages/evm/package.json b/packages/evm/package.json index 4cd2a9e5..6a297a8d 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -1,6 +1,6 @@ { "name": "@sei-js/evm", - "version": "1.4.0", + "version": "1.5.0", "description": "TypeScript library for EVM interactions on the Sei blockchain", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -31,13 +31,22 @@ "publishConfig": { "access": "public" }, - "dependencies": {}, + "dependencies": { + "@noble/curves": "^1.5.0", + "@noble/hashes": "^1.4.0", + "bech32": "^2.0.0", + "ethers": "^6.0.0", + "viem": "2.x" + }, "peerDependencies": { "ethers": "^6.0.0", "viem": "2.x" }, "devDependencies": { + "@types/jest": "^29.5.12", "ethers": "^6.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", "viem": "2.x" }, "exports": { diff --git a/packages/evm/src/index.ts b/packages/evm/src/index.ts index ae1a16ab..8ad9fad0 100644 --- a/packages/evm/src/index.ts +++ b/packages/evm/src/index.ts @@ -1 +1,2 @@ export * from './precompiles'; +export * from './utils'; \ No newline at end of file diff --git a/packages/evm/src/utils/index.ts b/packages/evm/src/utils/index.ts new file mode 100644 index 00000000..738cdfb9 --- /dev/null +++ b/packages/evm/src/utils/index.ts @@ -0,0 +1 @@ +export * from './walletUtils'; diff --git a/packages/evm/src/utils/walletUtils.spec.ts b/packages/evm/src/utils/walletUtils.spec.ts new file mode 100644 index 00000000..5e8f8551 --- /dev/null +++ b/packages/evm/src/utils/walletUtils.spec.ts @@ -0,0 +1,59 @@ +import { + generateAddressesFromPrivateKey, + deriveAddressesFromPublicKey, + isValidSeiAddress, + isValidEvmAddress +} from './walletUtils'; + +describe('Wallet Utilities', () => { + const testPrivateKey = 'e7b71175472a74bbd440b2a23a7530adab2ba849e1fe56abaaa303ee8f11e058'; + const testPublicKey = new Uint8Array([2, 149, 116, 84, 195, 81, 66, 126, 67, 17, 205, 167, 108, 133, 172, 118, 133, 233, 126, 164, 251, 148, 233, 54, 152, 96, 218, 227, 62, 121, 29, 124, 66]); + + describe('generateAddressesFromPrivateKey', () => { + it('should generate valid Sei and EVM addresses from a private key', () => { + const { seiAddress, evmAddress } = generateAddressesFromPrivateKey(testPrivateKey); + expect(isValidSeiAddress(seiAddress)).toBe(true); + expect(isValidEvmAddress(evmAddress)).toBe(true); + expect(seiAddress.startsWith('sei')).toBe(true); + expect(evmAddress.startsWith('0x')).toBe(true); + }); + + it('should throw an error for an invalid private key', () => { + expect(() => generateAddressesFromPrivateKey('invalid')).toThrow('Private key must be 32 bytes long.'); + }); + }); + + describe('deriveAddressesFromPublicKey', () => { + it('should derive valid Sei and EVM addresses from a public key', () => { + const { seiAddress, evmAddress } = deriveAddressesFromPublicKey(testPublicKey); + expect(isValidSeiAddress(seiAddress)).toBe(true); + expect(isValidEvmAddress(evmAddress)).toBe(true); + expect(seiAddress.startsWith('sei')).toBe(true); + expect(evmAddress.startsWith('0x')).toBe(true); + }); + }); + + describe('isValidSeiAddress', () => { + it('should return true for a valid Sei address', () => { + const { seiAddress } = generateAddressesFromPrivateKey(testPrivateKey); + expect(isValidSeiAddress(seiAddress)).toBe(true); + }); + + it('should return false for an invalid Sei address', () => { + const invalidAddress = 'invalid_address'; + expect(isValidSeiAddress(invalidAddress)).toBe(false); + }); + }); + + describe('isValidEvmAddress', () => { + it('should return true for a valid EVM address', () => { + const { evmAddress } = generateAddressesFromPrivateKey(testPrivateKey); + expect(isValidEvmAddress(evmAddress)).toBe(true); + }); + + it('should return false for an invalid EVM address', () => { + const invalidAddress = '0xinvalid_address'; + expect(isValidEvmAddress(invalidAddress)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/evm/src/utils/walletUtils.ts b/packages/evm/src/utils/walletUtils.ts new file mode 100644 index 00000000..1e6abb7c --- /dev/null +++ b/packages/evm/src/utils/walletUtils.ts @@ -0,0 +1,52 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { encode, decode, toWords } from 'bech32'; + +export interface AddressSet { + seiAddress: string; + evmAddress: string; +} + +export function generateAddressesFromPrivateKey(privateKeyHex: string): AddressSet { + const privateKey = Uint8Array.from(Buffer.from(privateKeyHex.padStart(64, '0'), 'hex')); + if (privateKey.length !== 32) { + throw new Error('Private key must be 32 bytes long.'); + } + + const publicKey = secp256k1.getPublicKey(privateKey, true); + return deriveAddressesFromPublicKey(publicKey); +} + +export function deriveAddressesFromPublicKey(publicKeyBytes: Uint8Array): AddressSet { + // SHA-256 and RIPEMD-160 hashing for Bech32 addresses + const sha256Digest = sha256(publicKeyBytes); + const ripemd160Digest = ripemd160(sha256Digest); + + // Convert to 5-bit groups for Bech32 encoding + const words = toWords(ripemd160Digest); + + // Bech32 address with "sei" prefix + const seiAddress = encode('sei', words); + + // Ethereum-style hex address + const publicKeyUncompressed = secp256k1.ProjectivePoint.fromHex(publicKeyBytes).toRawBytes(false).slice(1); + const keccakHash = keccak_256(publicKeyUncompressed); + const evmAddress = `0x${Buffer.from(keccakHash).slice(-20).toString('hex')}`; + + return { seiAddress, evmAddress }; +} + +export function isValidSeiAddress(address: string): boolean { + try { + const decoded = decode(address); + return decoded.prefix === 'sei' && decoded.words.length === 32; + } catch { + return false; + } +} + +export function isValidEvmAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address); +} \ No newline at end of file diff --git a/packages/evm/tsconfig.json b/packages/evm/tsconfig.json index 5aa365f8..7030218c 100644 --- a/packages/evm/tsconfig.json +++ b/packages/evm/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.base.json", "include": ["src"], "compilerOptions": { - "outDir": "./dist/types" + "outDir": "./dist/types", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true }, "typedocOptions": { "readme": "./README.md", From efad31f6ff3b8ffefae763051cf9ed1d4f425338 Mon Sep 17 00:00:00 2001 From: Cordt Date: Mon, 26 Aug 2024 23:46:25 -0600 Subject: [PATCH 2/3] modified: yarn.lock --- yarn.lock | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index 129c63e8..60dd9440 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3211,12 +3211,19 @@ dependencies: "@noble/hashes" "1.3.2" +"@noble/curves@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.5.0.tgz#7a9b9b507065d516e6dce275a1e31db8d2a100dd" + integrity sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A== + dependencies: + "@noble/hashes" "1.4.0" + "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== -"@noble/hashes@^1", "@noble/hashes@^1.0.0": +"@noble/hashes@1.4.0", "@noble/hashes@^1", "@noble/hashes@^1.0.0", "@noble/hashes@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== @@ -3992,7 +3999,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.5.5": +"@types/jest@^29.5.12", "@types/jest@^29.5.5": version "29.5.12" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== @@ -4902,7 +4909,7 @@ browserslist@^4.22.2, browserslist@^4.23.0: node-releases "^2.0.14" update-browserslist-db "^1.0.13" -bs-logger@0.x: +bs-logger@0.x, bs-logger@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== @@ -5696,6 +5703,13 @@ eip55@^2.1.1: dependencies: keccak "^3.0.3" +ejs@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== + dependencies: + jake "^10.8.5" + ejs@^3.1.7: version "3.1.9" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361" @@ -7754,7 +7768,7 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -jest@29.7.0: +jest@29.7.0, jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -8008,7 +8022,7 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.memoize@4.x: +lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== @@ -8099,7 +8113,7 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -make-error@1.x, make-error@^1.1.1: +make-error@1.x, make-error@^1.1.1, make-error@^1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -9416,6 +9430,11 @@ semver@^7.0.0, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: dependencies: lru-cache "^6.0.0" +semver@^7.6.3: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -10066,6 +10085,21 @@ ts-jest@29.1.1: semver "^7.5.3" yargs-parser "^21.0.1" +ts-jest@^29.2.5: + version "29.2.5" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.2.5.tgz#591a3c108e1f5ebd013d3152142cb5472b399d63" + integrity sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA== + dependencies: + bs-logger "^0.2.6" + ejs "^3.1.10" + fast-json-stable-stringify "^2.1.0" + jest-util "^29.0.0" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.6.3" + yargs-parser "^21.1.1" + ts-node@10.9.1: version "10.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" From b492deeacc14a757217564d00bb646d038c487d9 Mon Sep 17 00:00:00 2001 From: Cordt Date: Tue, 27 Aug 2024 00:15:45 -0600 Subject: [PATCH 3/3] new file: .changeset/nasty-radios-warn.md modified: package.json modified: packages/evm/package.json modified: packages/evm/src/utils/walletUtils.ts modified: yarn.lock --- .changeset/nasty-radios-warn.md | 5 +++++ package.json | 5 +++++ packages/evm/package.json | 4 ++++ packages/evm/src/utils/walletUtils.ts | 6 +++++- yarn.lock | 13 ++++++++++--- 5 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 .changeset/nasty-radios-warn.md diff --git a/.changeset/nasty-radios-warn.md b/.changeset/nasty-radios-warn.md new file mode 100644 index 00000000..831cf631 --- /dev/null +++ b/.changeset/nasty-radios-warn.md @@ -0,0 +1,5 @@ +--- +'@sei-js/evm': patch +--- + +Adds wallet utilities and tests diff --git a/package.json b/package.json index dc9d1fee..95920fae 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@changesets/cli": "^2.26.0", "axios": "^1.6.0", + "bech32": "^2.0.0", "glob": "^10.3.10", "tslib": "^2.3.0" }, @@ -43,6 +44,10 @@ "@swc-node/register": "~1.8.0", "@swc/core": "~1.3.85", "@swc/helpers": "~0.5.2", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/babel__template": "^7.4.4", + "@types/babel__traverse": "^7.20.6", "@types/jest": "^29.5.5", "@types/node": "20.8.2", "@typescript-eslint/eslint-plugin": "^7.4.0", diff --git a/packages/evm/package.json b/packages/evm/package.json index 6a297a8d..fd292e96 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -43,6 +43,10 @@ "viem": "2.x" }, "devDependencies": { + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/babel__template": "^7.4.4", + "@types/babel__traverse": "^7.20.6", "@types/jest": "^29.5.12", "ethers": "^6.0.0", "jest": "^29.7.0", diff --git a/packages/evm/src/utils/walletUtils.ts b/packages/evm/src/utils/walletUtils.ts index 1e6abb7c..bb6ba06d 100644 --- a/packages/evm/src/utils/walletUtils.ts +++ b/packages/evm/src/utils/walletUtils.ts @@ -2,7 +2,11 @@ import { sha256 } from '@noble/hashes/sha256'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { keccak_256 } from '@noble/hashes/sha3'; import { secp256k1 } from '@noble/curves/secp256k1'; -import { encode, decode, toWords } from 'bech32'; +import { bech32 } from 'bech32'; // Note the import statement +const encode = bech32.encode; +const decode = bech32.decode; +const toWords = bech32.toWords; + export interface AddressSet { seiAddress: string; diff --git a/yarn.lock b/yarn.lock index 60dd9440..9dba7a5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3903,7 +3903,7 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/babel__core@^7.1.14": +"@types/babel__core@^7.1.14", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -3914,14 +3914,14 @@ "@types/babel__template" "*" "@types/babel__traverse" "*" -"@types/babel__generator@*": +"@types/babel__generator@*", "@types/babel__generator@^7.6.8": version "7.6.8" resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== dependencies: "@babel/types" "^7.0.0" -"@types/babel__template@*": +"@types/babel__template@*", "@types/babel__template@^7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== @@ -3936,6 +3936,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/babel__traverse@^7.20.6": + version "7.20.6" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" + integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== + dependencies: + "@babel/types" "^7.20.7" + "@types/bn.js@*": version "5.1.5" resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.5.tgz#2e0dacdcce2c0f16b905d20ff87aedbc6f7b4bf0"