test: bitcoin address generation logic

This commit is contained in:
kyranjamie
2023-01-05 16:44:57 +01:00
committed by Fara Woolf
parent 22c97c49fa
commit 08d35e438f
6 changed files with 378 additions and 4 deletions

View File

@@ -137,6 +137,9 @@
"@reach/utils": "0.15.3",
"@reach/visually-hidden": "0.15.2",
"@reduxjs/toolkit": "1.8.4",
"@scure/base": "1.1.1",
"@scure/bip32": "1.1.1",
"@scure/bip39": "1.1.0",
"@segment/analytics-next": "1.46.0",
"@sentry/react": "7.27.0",
"@sentry/tracing": "7.28.0",
@@ -265,6 +268,7 @@
"audit-ci": "6.3.0",
"babel-loader": "9.1.0",
"base64-loader": "1.0.0",
"bip32": "3.1.0",
"bip39": "3.0.4",
"blns": "2.0.4",
"browserslist": "4.21.4",
@@ -303,6 +307,7 @@
"speed-measure-webpack-plugin": "1.5.0",
"stream-browserify": "3.0.0",
"svg-url-loader": "8.0.0",
"tiny-secp256k1": "2.2.1",
"ts-jest": "29.0.3",
"ts-node": "10.9.1",
"ts-unused-exports": "7.0.3",

View File

@@ -0,0 +1,46 @@
import { derivePayToWitnessPublicKeyHashAddressFromXpub } from './p2wpkh-address-gen';
describe('Bitcoin bech32 (P2WPKH address derivation', () => {
describe('from extended public key', () => {
const accounts = [
{
path: "m/84'/0'/0'",
extended_public_key:
'xpub6CwY13JDrzeY2oWjP9dbiyLHQh3JVWCvBTCfD7WREBUpBUmtCu4bgxfSGrvaLDbZaMdw2nsPeTFv6AokWkVqh4rbKpsxg7GgEu543Qwvyff',
private_key: 'L1FA9VHZNkgCBW9fS76zDHcjuK72LE4gGVAMnN67onRRCoDJvZJi',
public_key: '0211758b68eb9b0e4e9610c49739f2ce039732033ba47e125bbdf64ef6cd586ef3',
zeroIndexChildAddress: 'bc1qa4ypkks2kfpawyy5mautjfqc6wv703ckm7puux',
},
{
path: "m/84'/0'/1'",
mnemonic:
'token spatial butter drill city debate pipe shoot target pencil tonight gallery dog globe copy hybrid convince spell load maximum impose crazy engage way',
extended_public_key:
'xpub6CwY13JDrzeY55xGbiHxHwZSZpbkmrM7QMag3yVgZi62zaYFsBAUam1kghZZx4hDgDdkDzAMxc8xmpcyGAb1EoXoB7Vn7WTiUEaCEd3CcPq',
private_key: 'Kyhx4Zz1iYmCGx1gLnPE5ZFphBf16BoRKokU6B8KbxkJ7tM511de',
public_key: '025f6abba7947109c5e5ba0fed5e7b99b0ce5b06ccbca86539e6eca261c4507559',
zeroIndexChildAddress: 'bc1q5aptjy5l9q4qcykvccpwlqcvzydg744qkv94d3',
},
{
path: "m/84'/0'/2'",
mnemonic:
'token spatial butter drill city debate pipe shoot target pencil tonight gallery dog globe copy hybrid convince spell load maximum impose crazy engage way',
extended_public_key:
'xpub6CwY13JDrzeY7qyP5MCBqA3hmB9oX8mjpbt6YWPfCRb9fus8Yrt84xxzh1Ci2wyW8intyoxmr3MjCHCtbs458uboWZVV8WFeHZBveJHVG71',
private_key: 'L1CzwqocLUQgH6GeH6bBKRaRnGLF81249Wbd14uTzLaUGE5qMdD7',
public_key: '022b804094c9b74a93d51e6bb3b1ae8378027e810058bbcb34ac54f3a307a225d1',
zeroIndexChildAddress: 'bc1q253fdeyzuwx58xxssd3a2xw2gq7khhpmr6vgnh',
},
];
describe.each(accounts)('bitcoinjs-lib implementation', account => {
describe(account.path, () => {
const address = derivePayToWitnessPublicKeyHashAddressFromXpub(
account.extended_public_key,
0
);
test('bech 32 address', () => expect(address).toEqual(account.zeroIndexChildAddress));
});
});
});
});

View File

@@ -0,0 +1,12 @@
import { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
function deriveBip32KeychainFromExtendedPublicKey(xpub: string) {
return HDKey.fromExtendedKey(xpub);
}
export function derivePayToWitnessPublicKeyHashAddressFromXpub(xpub: string, index: number) {
const keychain = deriveBip32KeychainFromExtendedPublicKey(xpub);
const zeroAddressIndex = keychain.deriveChild(0).deriveChild(index);
return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(zeroAddressIndex.publicKey!) }).address;
}

View File

@@ -0,0 +1,209 @@
import { sha256 } from '@noble/hashes/sha256';
import { base58check } from '@scure/base';
import { HDKey } from '@scure/bip32';
import { hashP2WPKH } from '@stacks/transactions';
import bip32Factory from 'bip32';
import * as bip39 from 'bip39';
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from 'tiny-secp256k1';
import {
decodeCompressedWifPrivateKey,
deriveBtcBip49SeedFromMnemonic,
deriveRootBtcKeychain,
makePayToScriptHashAddress,
makePayToScriptHashAddressBytes,
makePayToScriptHashKeyHash,
payToScriptHashTestnetPrefix,
publicKeyToPayToScriptHashAddress,
} from './p2wsh-p2sh-address-gen';
describe('Bitcoin SegWit (P2WPKH-P2SH) address generation', () => {
const bip32 = bip32Factory(ecc);
//
// https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/bip32.spec.ts
describe('Sanity check tests copied from `bitcoinjs-lib` vs other libs', () => {
test('can create a BIP49, bitcoin testnet, account 0, external address', async () => {
const mnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = bip39.mnemonicToSeedSync(mnemonic);
expect(seed).toEqual(Buffer.from(await deriveBtcBip49SeedFromMnemonic(mnemonic)));
const root = bip32.fromSeed(seed);
const keychain = deriveRootBtcKeychain(seed);
expect(root.privateKey?.toString('hex')).toEqual(
Buffer.from(keychain.privateKey!).toString('hex')
);
const path = "m/49'/1'/0'/0/0";
const child = root.derivePath(path);
const bitcoinPayment = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wpkh({
pubkey: child.publicKey,
network: bitcoin.networks.testnet,
}),
network: bitcoin.networks.testnet,
});
expect(bitcoinPayment.address).toEqual('2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2');
});
});
const phrase =
'above view guide write long gift chimney own guide mirror word ski code monster gauge bracket until stem feed scale smart truth toy limb';
describe('Verify against wagyu results', () => {
// Keys generated with `wagyu`
// $ wagyu bitcoin import-hd -m "<phrase>" -d "m/49'/0'/0'/0/0" --format segwit --json
const keys = [
{
path: "m/49'/0'/0'/0/0",
extended_private_key:
'xprvA2WTEJy9NLu57C55yCCPvXLzGq6mGjL3oc81T7vMv2WYREFuAJV3HT4pJYF4a3JRCnyU95rgq4eY2X6cCJTJQYEHmHrvyfy5pCnPcqeTikK',
extended_public_key:
'xpub6FVodpW3CiTNKg9Z5DjQHfHiprwFgC3uAq3cFWKyUN3XJ2b3hqoHqFPJ9p9r4QK5f9fs1VztRMrjSy6M6HvVLtpC6KipJ2whmAhk9V3GZZ2',
private_key: 'L5iYDFDUDSGnjtWUT8gKDvCcsfMna5fAk6pQo5DZandks5r7Av4Q',
public_key: '03715f44ce96a11743c97e4ef5954e78482107a9658f1c5f33bc9e70dc171e56e5',
address: '3CTTwjVZ59ykFH2DSQpF3iLWM3fESjFcJ9',
format: 'p2sh_p2wpkh',
network: 'mainnet',
},
{
path: "m/49'/0'/0'/0/1",
extended_private_key:
'xprvA2WTEJy9NLu5ANouN242mgeiXNcndxwCRHRj3B3C96zWPj7Cgp22frkXKLGiRK59fg6nkGHHityZkVdjBfp7oLP8gf2jy2iHf21qaTWHQfd',
extended_public_key:
'xpub6FVodpW3CiTNNrtNU3b38pbT5QTH3Rf3nWMKqZSohSXVGXSMEMLHDf51AZpFphHQXCZzAMXGHraNyBmRXHKbgKQETn8mr6oUTAXBYJJBGEy',
private_key: 'L4Xt5Ricu9HAg3t92uyqNpnFXKXCgt6DuUtVMkaTsqgXs7rnxjSY',
public_key: '02166ce8acc10a07f877436d673c1876ad2b68d7c78075972d4b2d9f8e1d0d984d',
address: '36R4QBx4HqRSiRswcFeCe6KUgk2JY9aP87',
format: 'p2sh_p2wpkh',
network: 'mainnet',
},
{
path: "m/49'/0'/0'/0/2",
extended_private_key:
'xprvA2WTEJy9NLu5E4vHyjZWFKoTnibqRqNquBzPR7sRoMn44bvpq6ES7cbRmxmxZAtiTDFvRUFWzpsYqbuNF4WapLdJJzrYTDDY6k4QhqHEkXG',
extended_public_key:
'xpub6FVodpW3CiTNSYzm5m6WcTkCLkSKqJ6hGQuzDWH3MhK2wQFyNdYgfQuudCnLj4afakVMnLpHBuAY13aHFh3giri7MRZ8gEddLtr9wdgcvpn',
private_key: 'L2aBwidPCi2YjxDriNAtxfrMFbS3PsKeUUSnnt8cQQRKvpPciUqo',
public_key: '0218e2229c75d57f2a0bd6dfdfa50a1a736d19fb40a1f18a675d34960b088df01e',
address: '3BU1wA95ELhgweMSazGh42CHD5K64XGUop',
format: 'p2sh_p2wpkh',
network: 'mainnet',
},
{
path: "m/49'/0'/0'/0/3",
extended_private_key:
'xprvA2WTEJy9NLu5FKNrRe3coYxbKXjjzibJ6uouC9v29s6Ut8KJvmqXWmvzPTb9wPfRjYzvcq91QyV6B7P38XmZpTquTDoVyp4vv5baiyf8EZT',
extended_public_key:
'xpub6FVodpW3CiTNToTKXfadAguKsZaEQBK9U8jVzYKdiCdTkveTUK9n4aFUEiuixKhQeqrrqX9iKTYFmpJXdc2im8y2JzYCuiEZvegLuTAetxJ',
private_key: 'L2Mx4mkmuQMnRxf1gCYSSEugDj6TeDS45eYjXYdanJ7MEX9Xp8Fe',
public_key: '02bf94312be9021d61d1ed917c5e8542d215180afe5db35c5574e3382b3b8469f0',
address: '3MCzNqbNy7k8hnyenwpsdHahY2yBVQJQsz',
format: 'p2sh_p2wpkh',
network: 'mainnet',
},
] as const;
describe.each(keys)('Core libraries: bip32, bip39, bitcoinjs-lib', key => {
const seed = bip39.mnemonicToSeedSync(phrase);
const root = bip32.fromSeed(seed);
const child = root.derivePath(key.path);
describe(key.path, () => {
test(`public key`, () => expect(child.publicKey.toString('hex')).toEqual(key.public_key));
test(`extended public key`, () =>
expect(child.neutered().toBase58()).toEqual(key.extended_public_key));
test(`private key`, () =>
expect(child.privateKey).toEqual(
Buffer.from(decodeCompressedWifPrivateKey(key.private_key))
));
test(`extended private key`, () =>
expect(child.privateKey).toEqual(bip32.fromBase58(key.extended_private_key).privateKey));
test(`segwit address`, () => {
const bitcoinPayment = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wpkh({ pubkey: child.publicKey }),
});
expect(bitcoinPayment.address).toEqual(key.address);
});
});
});
describe.each(keys)('@scure/*', key => {
let seed: Uint8Array;
let root: HDKey;
let child: HDKey;
beforeAll(async () => {
seed = await deriveBtcBip49SeedFromMnemonic(phrase);
root = deriveRootBtcKeychain(seed);
child = root.derive(key.path);
});
describe(key.path, () => {
test(`public key`, () =>
expect(Buffer.from(child.publicKey!).toString('hex')).toEqual(key.public_key));
test(`extended public key`, () =>
expect(child.publicExtendedKey).toEqual(key.extended_public_key));
test(`private key`, () =>
expect(child.privateKey).toEqual(decodeCompressedWifPrivateKey(key.private_key)));
test(`extended private key`, () =>
expect(child.privateKey).toEqual(
HDKey.fromExtendedKey(key.extended_private_key).privateKey
));
test(`extended private key`, () =>
expect(child.privateExtendedKey).toEqual(key.extended_private_key));
test(`segwit address`, () => {
expect(publicKeyToPayToScriptHashAddress(child.publicKey!, key.network)).toEqual(
key.address
);
});
});
});
});
// Replicating test vector from BIP
// https://en.bitcoin.it/wiki/BIP_0049
test('BIP-0049 test vector', () => {
const publicKey = Buffer.from(
'03a1af804ac108a8a51782198c2d034b28bf90c8803f5a53f76276fa69a4eae77f',
'hex'
);
const hash = makePayToScriptHashKeyHash(publicKey);
// stacks.js implementation
const addressBytesFromStacks = hashP2WPKH(publicKey);
expect(addressBytesFromStacks).toEqual('336caa13e08b96080a32b5d818d59b4ab3b36742');
// wallet implementation
const addressBytes = makePayToScriptHashAddressBytes(hash);
const addressBytesHex = Buffer.from(addressBytes).toString('hex');
expect(addressBytesHex).toEqual('336caa13e08b96080a32b5d818d59b4ab3b36742');
// compare lib output
expect(addressBytesFromStacks).toEqual(addressBytesHex);
const address = base58check(sha256).encode(
Buffer.concat([
Buffer.of(payToScriptHashTestnetPrefix),
Buffer.from(addressBytesFromStacks, 'hex'),
])
);
const addressWithLib = makePayToScriptHashAddress(addressBytes, 'testnet');
expect(address).toEqual(addressWithLib);
expect(addressWithLib).toEqual('2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2');
});
});

View File

@@ -0,0 +1,78 @@
import { ripemd160 } from '@noble/hashes/ripemd160';
import { sha256 } from '@noble/hashes/sha256';
import { base58check } from '@scure/base';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeed } from '@scure/bip39';
import * as bitcoin from 'bitcoinjs-lib';
export async function deriveBtcBip49SeedFromMnemonic(mnemonic: string) {
return mnemonicToSeed(mnemonic);
}
export function deriveRootBtcKeychain(seed: Uint8Array) {
return HDKey.fromMasterSeed(seed);
}
// ts-unused-exports:disable-next-line
export async function deriveBtcPayment(
publicKey: Uint8Array | Buffer,
defaultNetwork: 'mainnet' | 'testnet' = 'mainnet'
) {
const pubkey = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey);
const network = defaultNetwork === 'mainnet' ? undefined : bitcoin.networks.testnet;
return bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wpkh({
pubkey,
network,
}),
network,
});
}
export function decodeCompressedWifPrivateKey(key: string) {
// https://en.bitcoinwiki.org/wiki/Wallet_import_format
// Decode Compressed WIF format private key
const compressedWifFormatPrivateKey = base58check(sha256).decode(key);
// Drop leading network byte, trailing public key SEC format byte
return compressedWifFormatPrivateKey.slice(1, compressedWifFormatPrivateKey.length - 1);
}
type BitcoinNetwork = 'mainnet' | 'testnet';
// https://en.bitcoin.it/wiki/List_of_address_prefixes
const payToScriptHashMainnetPrefix = 0x05;
export const payToScriptHashTestnetPrefix = 0xc4;
const payToScriptHashPrefixMap: Record<BitcoinNetwork, number> = {
mainnet: payToScriptHashMainnetPrefix,
testnet: payToScriptHashTestnetPrefix,
};
function hash160(input: Uint8Array) {
return ripemd160(sha256(input));
}
export function makePayToScriptHashKeyHash(publicKey: Uint8Array) {
return hash160(publicKey);
}
export function makePayToScriptHashAddressBytes(keyHash: Uint8Array) {
const redeemScript = Uint8Array.from([
...Uint8Array.of(0x00),
...Uint8Array.of(keyHash.length),
...keyHash,
]);
return hash160(redeemScript);
}
export function makePayToScriptHashAddress(addressBytes: Uint8Array, network: BitcoinNetwork) {
const networkByte = payToScriptHashPrefixMap[network];
const addressWithPrefix = Uint8Array.from([networkByte, ...addressBytes]);
return base58check(sha256).encode(addressWithPrefix);
}
export function publicKeyToPayToScriptHashAddress(publicKey: Uint8Array, network: BitcoinNetwork) {
const hash = makePayToScriptHashKeyHash(publicKey);
const addrBytes = makePayToScriptHashAddressBytes(hash);
return makePayToScriptHashAddress(addrBytes, network);
}

View File

@@ -3238,12 +3238,12 @@
resolved "https://registry.yarnpkg.com/@schemastore/web-manifest/-/web-manifest-0.0.5.tgz#97f0b1f14d095189c5672309e4975760278461b2"
integrity sha512-3SF3OwzJ+PIqYDVW0MXoUAyypyx7N5RlYj2zek36qVuDUgoiI65q0ietwuxyVtbTRYJyP64KBGKvKqHzbIxdfA==
"@scure/base@~1.1.0":
"@scure/base@1.1.1", "@scure/base@~1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@scure/bip32@^1.1.1":
"@scure/bip32@1.1.1", "@scure/bip32@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.1.tgz#f62e4a2f13cc3e5e720ad81b7582b8631ae6835a"
integrity sha512-UmI+liY7np2XakaW+6lMB6HZnpczWk1yXZTxvg8TM8MdOcKHCGL1YkraGj8eAjPfMwFNiAyek2hXmS/XFbab8g==
@@ -3252,7 +3252,7 @@
"@noble/secp256k1" "~1.7.0"
"@scure/base" "~1.1.0"
"@scure/bip39@^1.1.0":
"@scure/bip39@1.1.0", "@scure/bip39@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a"
integrity sha512-pwrPOS16VeTKg98dYXQyIjJEcWfz7/1YJIwxUEPFfQPtc86Ym/1sVgQ2RLoD43AazMk2l/unK4ITySSpW2+82w==
@@ -7189,6 +7189,18 @@ bip174@^2.0.1:
resolved "https://registry.yarnpkg.com/bip174/-/bip174-2.1.0.tgz#cd3402581feaa5116f0f00a0eaee87a5843a2d30"
integrity sha512-lkc0XyiX9E9KiVAS1ZiOqK1xfiwvf4FXDDdkDq5crcDzOq+xGytY+14qCsqz7kCiy8rpN1CRNfacRhf9G3JNSA==
bip32@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bip32/-/bip32-3.1.0.tgz#ce90e020d0e6b41e891a0122ff053efabcce1ccc"
integrity sha512-eoeajYEzJ4d6yyVtby8C+XkCeKItiC4Mx56a0M9VaqTMC73SWOm4xVZG7SaR8e/yp4eSyky2XcBpH3DApPdu7Q==
dependencies:
bs58check "^2.1.1"
create-hash "^1.2.0"
create-hmac "^1.1.7"
ripemd160 "^2.0.2"
typeforce "^1.11.5"
wif "^2.0.6"
bip32@^2.0.4:
version "2.0.6"
resolved "https://registry.yarnpkg.com/bip32/-/bip32-2.0.6.tgz#6a81d9f98c4cd57d05150c60d8f9e75121635134"
@@ -15524,7 +15536,7 @@ ripemd160-min@^0.0.6:
resolved "https://registry.yarnpkg.com/ripemd160-min/-/ripemd160-min-0.0.6.tgz#a904b77658114474d02503e819dcc55853b67e62"
integrity sha512-+GcJgQivhs6S9qvLogusiTcS9kQUfgR75whKuy5jIhuiOfQuJ8fjqxV6EGD5duH1Y/FawFUMtMhyeq3Fbnib8A==
ripemd160@^2.0.0, ripemd160@^2.0.1:
ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c"
integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==
@@ -16837,6 +16849,13 @@ tiny-hashes@^1.0.1:
resolved "https://registry.yarnpkg.com/tiny-hashes/-/tiny-hashes-1.0.1.tgz#ddbe9060312ddb4efe0a174bb3a27e1331c425a1"
integrity sha512-knIN5zj4fl7kW4EBU5sLP20DWUvi/rVouvJezV0UAym2DkQaqm365Nyc8F3QEiOvunNDMxR8UhcXd1d5g+Wg1g==
tiny-secp256k1@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/tiny-secp256k1/-/tiny-secp256k1-2.2.1.tgz#a61d4791b7031aa08a9453178a131349c3e10f9b"
integrity sha512-/U4xfVqnVxJXN4YVsru0E6t5wVncu2uunB8+RVR40fYUxkKYUPS10f+ePQZgFBoE/Jbf9H1NBveupF2VmB58Ng==
dependencies:
uint8array-tools "0.0.7"
tiny-secp256k1@^1.1.1, tiny-secp256k1@^1.1.3:
version "1.1.6"
resolved "https://registry.yarnpkg.com/tiny-secp256k1/-/tiny-secp256k1-1.1.6.tgz#7e224d2bee8ab8283f284e40e6b4acb74ffe047c"
@@ -17160,6 +17179,11 @@ uid-number@0.0.6:
resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
integrity sha512-c461FXIljswCuscZn67xq9PpszkPT6RjheWFQTgCyabJrTUozElanb0YEqv2UGgk247YpcJkFBuSGNvBlpXM9w==
uint8array-tools@0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/uint8array-tools/-/uint8array-tools-0.0.7.tgz#a7a2bb5d8836eae2fade68c771454e6a438b390d"
integrity sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"