Task/Remove vegawallet service api client (#916)

* chore: remove generated vegawallet client code and implement in rest connector

* feat: add zod validation

* feat: handle specific auth/token delete case

* feat: make withdraw dialog match vega tx dialog

* fix: response stub to be right shape, add content type to requests

* chore: revert unrelated classname change
This commit is contained in:
Matthew Russell 2022-08-01 09:21:31 +01:00 committed by GitHub
parent 76efc3f68b
commit 4269060c9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 296 additions and 186 deletions

View File

@ -1,2 +1 @@
export { VoteDetails } from './vote-details';
export { VOTE_VALUE_MAP } from './vote-types';

View File

@ -2,8 +2,7 @@ import { captureException, captureMessage } from '@sentry/minimal';
import * as React from 'react';
import { VoteValue } from '../../../../__generated__/globalTypes';
import { VOTE_VALUE_MAP } from './vote-types';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useVegaWallet, VegaWalletVoteValue } from '@vegaprotocol/wallet';
export type Vote = {
value: VoteValue;
@ -101,7 +100,7 @@ export function useUserVote(
pubKey: keypair.pub,
propagate: true,
voteSubmission: {
value: VOTE_VALUE_MAP[value],
value: VegaWalletVoteValue[value],
proposalId,
},
};

View File

@ -1,6 +0,0 @@
import { VoteValue } from '../../../../__generated__/globalTypes';
export const VOTE_VALUE_MAP = {
[VoteValue.Yes]: 'VALUE_YES',
[VoteValue.No]: 'VALUE_NO',
} as const;

View File

@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next';
import { useAppState } from '../../contexts/app-state/app-state-context';
import { BigNumber } from '../../lib/bignumber';
import { removeDecimal } from '../../lib/decimals';
import type { UndelegateSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { UndelegateSubmissionBody } from '@vegaprotocol/vegawallet-service-api-client';
interface PendingStakeProps {
pendingAmount: BigNumber;

View File

@ -26,11 +26,11 @@ import {
Radio,
RadioGroup,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type {
DelegateSubmissionBody,
UndelegateSubmissionBody,
} from '@vegaprotocol/vegawallet-service-api-client';
} from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
export const PARTY_DELEGATIONS_QUERY = gql`
query PartyDelegations($partyId: ID!) {

View File

@ -1,5 +1,5 @@
import merge from 'lodash/merge';
import type { TransactionResponse } from '@vegaprotocol/vegawallet-service-api-client';
import type { TransactionResponse } from '@vegaprotocol/wallet';
import type { PartialDeep } from 'type-fest';
declare global {
@ -17,7 +17,10 @@ export function addMockVegaWalletCommands() {
'mockVegaCommandSync',
(override?: PartialDeep<TransactionResponse>) => {
const defaultTransactionResponse = {
txId: 'tx-id',
txHash: 'tx-hash',
sentAt: new Date().toISOString(),
receivedAt: new Date().toISOString(),
tx: {
input_data:
'CPe6vpiqsPqxDBDC1w7KPkoKQGE4Y2M0NjUwMjhiMGY4OTM4YTYzZTEzNDViYzM2ODc3ZWRmODg4MjNmOWU0ZmI4ZDRlN2VkMmFlMzAwNzA3ZTMYASABKAM4Ag==',
@ -28,7 +31,7 @@ export function addMockVegaWalletCommands() {
version: 1,
},
From: {
PubKey: Cypress.env('vegaPublicKey'),
PubKey: Cypress.env('VEGA_PUBLIC_KEY'),
},
version: 2,
pow: {

View File

@ -51,14 +51,11 @@ export const useOrderEdit = (order: OrderFields | null) => {
orderAmendment: {
orderId: order.id,
marketId: order.market.id,
// @ts-ignore fix me please!
price: {
value: removeDecimal(args.price, order.market.decimalPlaces),
},
timeInForce: VegaWalletOrderTimeInForce[order.timeInForce],
// @ts-ignore fix me please!
sizeDelta: 0,
// @ts-ignore fix me please!
expiresAt: order.expiresAt
? {
value: toNanoSeconds(new Date(order.expiresAt)), // Wallet expects timestamp in nanoseconds

View File

@ -1,43 +1,80 @@
import type { Configuration } from '@vegaprotocol/vegawallet-service-api-client';
import {
createConfiguration,
ServerConfiguration,
DefaultApi,
} from '@vegaprotocol/vegawallet-service-api-client';
import { LocalStorage } from '@vegaprotocol/react-helpers';
import { WALLET_CONFIG } from '../storage-keys';
import type { VegaConnector } from './vega-connector';
import type { TransactionSubmission } from '../wallet-types';
import { z } from 'zod';
// Perhaps there should be a default ConnectorConfig that others can extend off. Do all connectors
// need to use local storage, I don't think so...
interface RestConnectorConfig {
token: string | null;
connector: 'rest';
url: string | null;
}
type Endpoint = 'auth/token' | 'command/sync' | 'keys';
export const AuthTokenSchema = z.object({
token: z.string(),
});
export const TransactionResponseSchema = z.object({
txId: z.string(),
txHash: z.string(),
tx: z.object({
From: z.object({
PubKey: z.string(),
}),
input_data: z.string(),
pow: z.object({
tid: z.string(),
nonce: z.number(),
}),
signature: z.object({
algo: z.string(),
value: z.string(),
version: z.number(),
}),
}),
sentAt: z.string(),
receivedAt: z.string(),
});
export const GetKeysSchema = z.object({
keys: z.array(
z.object({
algorithm: z.object({
name: z.string(),
version: z.number(),
}),
index: z.number(),
meta: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
pub: z.string(),
tainted: z.boolean(),
})
),
});
/**
* Connector for using the Vega Wallet Service rest api, requires authentication to get a session token
*/
export class RestConnector implements VegaConnector {
configKey = WALLET_CONFIG;
apiConfig: Configuration;
service: DefaultApi;
description = 'Connects using REST to a running Vega wallet service';
url: string | null = null;
token: string | null = null;
constructor() {
const cfg = this.getConfig();
// If theres a stored auth token create api config with bearer authMethod
this.apiConfig = cfg?.token
? createConfiguration({
authMethods: {
bearer: `Bearer ${cfg.token}`,
},
})
: createConfiguration();
this.service = new DefaultApi(this.apiConfig);
if (cfg) {
this.token = cfg.token;
this.url = cfg.url;
}
}
async authenticate(
@ -48,26 +85,22 @@ export class RestConnector implements VegaConnector {
}
) {
try {
const service = new DefaultApi(
createConfiguration({
baseServer: new ServerConfiguration<Record<string, never>>(url, {}),
})
);
this.url = url;
const res = await service.authTokenPost(params);
const res = await this.request('auth/token', {
method: 'post',
body: JSON.stringify(params),
});
// Renew service instance with default bearer authMethod now that we have the token
this.service = new DefaultApi(
createConfiguration({
baseServer: new ServerConfiguration<Record<string, never>>(url, {}),
authMethods: {
bearer: `Bearer ${res.token}`,
},
})
);
const data = AuthTokenSchema.parse(res.data);
// Store the token, and other things for later
this.setConfig({ connector: 'rest', token: res.token });
this.setConfig({
connector: 'rest',
token: data.token,
url: this.url,
});
this.token = data.token;
return { success: true, error: null };
} catch (err) {
@ -77,10 +110,17 @@ export class RestConnector implements VegaConnector {
async connect() {
try {
const res = await this.service.keysGet();
return res.keys;
const res = await this.request('keys', {
method: 'get',
headers: {
authorization: `Bearer ${this.token}`,
},
});
const data = GetKeysSchema.parse(res.data);
return data.keys;
} catch (err) {
console.error(err);
// keysGet failed, its likely that the session has expired so remove the token from storage
this.clearConfig();
return null;
@ -89,7 +129,12 @@ export class RestConnector implements VegaConnector {
async disconnect() {
try {
await this.service.authTokenDelete();
await this.request('auth/token', {
method: 'delete',
headers: {
authorization: `Bearer ${this.token}`,
},
});
} catch (err) {
console.error(err);
} finally {
@ -101,28 +146,26 @@ export class RestConnector implements VegaConnector {
async sendTx(body: TransactionSubmission) {
try {
const res = await this.service.commandSyncPost(body);
return res;
const res = await this.request('command/sync', {
method: 'post',
body: JSON.stringify(body),
headers: {
authorization: `Bearer ${this.token}`,
},
});
if (res.status === 401) {
// User rejected
return null;
}
const data = TransactionResponseSchema.parse(res.data);
return data;
} catch (err) {
return this.handleSendTxError(err);
}
}
private handleSendTxError(err: unknown) {
const unexpectedError = { error: 'Something went wrong' };
if (isServiceError(err)) {
if (err.code === 401) {
return { error: 'User rejected' };
}
try {
return JSON.parse(err.body ?? '');
} catch {
return unexpectedError;
}
} else {
return unexpectedError;
return {
error: 'Failed to fetch',
};
}
}
@ -146,18 +189,29 @@ export class RestConnector implements VegaConnector {
private clearConfig() {
LocalStorage.removeItem(this.configKey);
}
}
interface ServiceError {
code: number;
body: string | undefined;
headers: object;
}
private async request(endpoint: Endpoint, options: RequestInit) {
const fetchResult = await fetch(`${this.url}/${endpoint}`, {
...options,
headers: {
...options.headers,
'Content-Type': 'application/json',
},
});
export const isServiceError = (err: unknown): err is ServiceError => {
// Some responses don't contain body object
if (typeof err === 'object' && err !== null && 'code' in err) {
return true;
// auth/token delete doesnt return json
if (endpoint === 'auth/token' && options.method === 'delete') {
const textResult = await fetchResult.text();
return {
status: fetchResult.status,
data: textResult,
};
} else {
const jsonResult = await fetchResult.json();
return {
status: fetchResult.status,
data: jsonResult,
};
}
}
return false;
};
}

View File

@ -1,8 +1,8 @@
import type { VegaKey } from '../wallet-types';
import type {
VegaKey,
TransactionSubmission,
TransactionResponse,
} from '@vegaprotocol/vegawallet-service-api-client';
import type { TransactionSubmission } from '../wallet-types';
} from '../wallet-types';
type ErrorResponse =
| {

View File

@ -1,18 +1,10 @@
import type {
VegaKey,
TransactionResponse,
} from '@vegaprotocol/vegawallet-service-api-client';
import type { TransactionError, VegaKey } from './wallet-types';
import { createContext } from 'react';
import type { VegaConnector } from './connectors';
import type { TransactionSubmission } from './wallet-types';
export type SendTxError =
| {
error: string;
}
| {
errors: object;
};
import type {
TransactionSubmission,
TransactionResponse,
} from './wallet-types';
export interface VegaKeyExtended extends VegaKey {
name: string;
@ -40,7 +32,7 @@ export interface VegaWalletContextShape {
/** Send a transaction to the network, only order submissions for now */
sendTx: (
tx: TransactionSubmission
) => Promise<TransactionResponse | SendTxError> | null;
) => Promise<TransactionResponse | TransactionError> | null;
}
export const VegaWalletContext = createContext<

View File

@ -1,5 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { VegaKey } from '@vegaprotocol/vegawallet-service-api-client';
import type { VegaKey } from './wallet-types';
import { RestConnector } from './connectors';
import { useVegaWallet } from './use-vega-wallet';
import { VegaWalletProvider } from './provider';

View File

@ -3,7 +3,7 @@ import type { VegaWalletContextShape } from './context';
import { VegaWalletContext } from './context';
import type { ReactNode } from 'react';
import { useVegaTransaction, VegaTxStatus } from './use-vega-transaction';
import type { OrderSubmissionBody } from '@vegaprotocol/vegawallet-service-api-client';
import type { OrderSubmissionBody } from './wallet-types';
const defaultWalletContext = {
keypair: null,

View File

@ -1,8 +1,7 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { TransactionSubmission } from './wallet-types';
import type { TransactionError, TransactionSubmission } from './wallet-types';
import { useVegaWallet } from './use-vega-wallet';
import type { SendTxError } from './context';
import { VegaTransactionDialog } from './vega-transaction-dialog';
import type { Intent } from '@vegaprotocol/ui-toolkit';
@ -23,7 +22,7 @@ export enum VegaTxStatus {
export interface VegaTxState {
status: VegaTxStatus;
error: object | null;
error: TransactionError | null;
txHash: string | null;
signature: string | null;
dialogOpen: boolean;
@ -49,7 +48,7 @@ export const useVegaTransaction = () => {
}, []);
const handleError = useCallback(
(error: SendTxError) => {
(error: TransactionError) => {
setTransaction({ error, status: VegaTxStatus.Error });
},
[setTransaction]
@ -76,23 +75,23 @@ export const useVegaTransaction = () => {
const res = await sendTx(tx);
if (res === null) {
setTransaction({ status: VegaTxStatus.Default });
return null;
// User rejected
reset();
return;
}
if ('errors' in res) {
if (isError(res)) {
handleError(res);
} else if ('error' in res) {
if (res.error === 'User rejected') {
reset();
} else {
handleError(res);
}
} else if (res.tx?.signature?.value && res.txHash) {
return;
}
if (res.tx?.signature?.value && res.txHash) {
setTransaction({
status: VegaTxStatus.Pending,
txHash: res.txHash,
signature: res.tx.signature.value,
});
return {
signature: res.tx.signature?.value,
};
@ -125,3 +124,14 @@ export const useVegaTransaction = () => {
TransactionDialog,
};
};
const isError = (error: unknown): error is TransactionError => {
if (
error !== null &&
typeof error === 'object' &&
('error' in error || 'errors' in error)
) {
return true;
}
return false;
};

View File

@ -57,7 +57,7 @@ describe('VegaTransactionDialog', () => {
{...props}
transaction={{
...props.transaction,
error: { message: 'rejected' },
error: { error: 'rejected' },
status: VegaTxStatus.Error,
}}
/>

View File

@ -1,5 +1,4 @@
import { useEnvironment } from '@vegaprotocol/environment';
import get from 'lodash/get';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import type { ReactNode } from 'react';
@ -71,16 +70,18 @@ export const VegaDialog = ({ transaction }: VegaDialogProps) => {
}
if (transaction.status === VegaTxStatus.Error) {
return (
<div data-testid={transaction.status}>
{transaction.error && (
<pre className="text-ui break-all whitespace-pre-wrap">
{get(transaction.error, 'error') ??
JSON.stringify(transaction.error, null, 2)}
</pre>
)}
</div>
);
let content = null;
if (transaction.error) {
if ('errors' in transaction.error) {
content = transaction.error.errors['*'].map((e) => <p>{e}</p>);
} else if ('error' in transaction.error) {
content = <p>{transaction.error.error}</p>;
} else {
content = <p>{t('Something went wrong')}</p>;
}
}
return <div data-testid={transaction.status}>{content}</div>;
}
if (transaction.status === VegaTxStatus.Pending) {

View File

@ -1,12 +1,88 @@
import type {
DelegateSubmissionBody,
OrderCancellationBody,
OrderSubmissionBody,
UndelegateSubmissionBody,
VoteSubmissionBody,
WithdrawSubmissionBody,
OrderAmendmentBody,
} from '@vegaprotocol/vegawallet-service-api-client';
import type { z } from 'zod';
import type { GetKeysSchema, TransactionResponseSchema } from './connectors';
import type { IterableElement } from 'type-fest';
interface BaseTransaction {
pubKey: string;
propagate: boolean;
}
export interface DelegateSubmissionBody extends BaseTransaction {
delegateSubmission: {
nodeId: string;
amount: string;
};
}
export interface UndelegateSubmissionBody extends BaseTransaction {
undelegateSubmission: {
nodeId: string;
amount: string;
method: 'METHOD_NOW' | 'METHOD_AT_END_OF_EPOCH';
};
}
export interface OrderSubmissionBody extends BaseTransaction {
orderSubmission: {
marketId: string;
reference?: string;
type: VegaWalletOrderType;
side: VegaWalletOrderSide;
timeInForce: VegaWalletOrderTimeInForce;
size: string;
price?: string;
expiresAt?: string;
};
}
export interface OrderCancellationBody extends BaseTransaction {
orderCancellation: {
orderId: string;
marketId: string;
};
}
export interface OrderAmendmentBody extends BaseTransaction {
orderAmendment: {
marketId: string;
orderId: string;
reference?: string;
timeInForce: VegaWalletOrderTimeInForce;
sizeDelta?: number;
// Note this is soon changing to price?: string
price?: {
value: string;
};
// Note this is soon changing to expiresAt?: number
expiresAt?: {
value: string;
};
};
}
export interface VoteSubmissionBody extends BaseTransaction {
voteSubmission: {
value: VegaWalletVoteValue;
proposalId: string;
};
}
export interface WithdrawSubmissionBody extends BaseTransaction {
withdrawSubmission: {
amount: string;
asset: string;
ext: {
erc20: {
receiverAddress: string;
};
};
};
}
export enum VegaWalletVoteValue {
Yes = 'VALUE_YES',
No = 'VALUE_NO',
}
export enum VegaWalletOrderType {
Market = 'TYPE_MARKET',
@ -36,3 +112,17 @@ export type TransactionSubmission =
| DelegateSubmissionBody
| UndelegateSubmissionBody
| OrderAmendmentBody;
export type TransactionResponse = z.infer<typeof TransactionResponseSchema>;
export type GetKeysResponse = z.infer<typeof GetKeysSchema>;
export type VegaKey = IterableElement<GetKeysResponse['keys']>;
export type TransactionError =
| {
errors: {
'*': string[];
};
}
| {
error: string;
};

View File

@ -69,6 +69,18 @@ const getProps = (
ethTx: EthTxState,
ethUrl: string
) => {
const renderVegaTxError = () => {
if (vegaTx.error) {
if ('errors' in vegaTx.error) {
return vegaTx.error.errors['*'].map((e) => <p>{e}</p>);
} else if ('error' in vegaTx.error) {
return <p>{vegaTx.error.error}</p>;
} else {
return <p>{t('Something went wrong')}</p>;
}
}
return null;
};
const vegaTxPropsMap: Record<VegaTxStatus, DialogProps> = {
[VegaTxStatus.Default]: {
title: '',
@ -80,15 +92,7 @@ const getProps = (
title: t('Withdrawal transaction failed'),
icon: <Icon name="warning-sign" size={20} />,
intent: Intent.Danger,
children: (
<Step>
{vegaTx.error && (
<pre className="text-ui break-all whitespace-pre-wrap">
{JSON.stringify(vegaTx.error, null, 2)}
</pre>
)}
</Step>
),
children: <Step>{renderVegaTxError()}</Step>,
},
[VegaTxStatus.Requested]: {
title: t('Confirm withdrawal'),

View File

@ -30,7 +30,6 @@
"@sentry/react": "^6.19.2",
"@sentry/tracing": "^6.19.2",
"@testing-library/user-event": "^14.2.1",
"@vegaprotocol/vegawallet-service-api-client": "0.4.15",
"@walletconnect/ethereum-provider": "^1.7.5",
"@web3-react/core": "8.0.20-beta.0",
"@web3-react/metamask": "8.0.16-beta.0",

View File

@ -6693,15 +6693,6 @@
"@typescript-eslint/types" "5.22.0"
eslint-visitor-keys "^3.0.0"
"@vegaprotocol/vegawallet-service-api-client@0.4.15":
version "0.4.15"
resolved "https://registry.yarnpkg.com/@vegaprotocol/vegawallet-service-api-client/-/vegawallet-service-api-client-0.4.15.tgz#b303fec121b9b334a678161a6f66b360aeed5f0d"
integrity sha512-YwJkUgFvFqpA1xPYQ30ILGddgzjwD9lclsu1GvwK2AUX/8e3iUcXyr37wLd/t8mDZ7P3Zb2AsuLJP8uZ6E1GHQ==
dependencies:
es6-promise "^4.2.4"
url-parse "^1.4.3"
whatwg-fetch "^3.0.0"
"@walletconnect/browser-utils@^1.7.7":
version "1.7.7"
resolved "https://registry.yarnpkg.com/@walletconnect/browser-utils/-/browser-utils-1.7.7.tgz#4ae0db1ddf49be179ea556af842db3b7afce973d"
@ -11543,11 +11534,6 @@ es6-object-assign@^1.1.0:
resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c"
integrity sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw=
es6-promise@^4.2.4:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
es6-shim@^0.35.5:
version "0.35.6"
resolved "https://registry.yarnpkg.com/es6-shim/-/es6-shim-0.35.6.tgz#d10578301a83af2de58b9eadb7c2c9945f7388a0"
@ -18378,11 +18364,6 @@ querystring@^0.2.0:
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd"
integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==
querystringify@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@ -21666,14 +21647,6 @@ url-loader@^4.1.1:
mime-types "^2.1.27"
schema-utils "^3.0.0"
url-parse@^1.4.3:
version "1.5.10"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
dependencies:
querystringify "^2.1.1"
requires-port "^1.0.0"
url@^0.11.0, url@~0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@ -22293,11 +22266,6 @@ whatwg-encoding@^2.0.0:
dependencies:
iconv-lite "0.6.3"
whatwg-fetch@^3.0.0:
version "3.6.2"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==
whatwg-mimetype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"