Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SessionClient#waitForTransaction #988

Merged
merged 1 commit into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 53 additions & 15 deletions packages/client/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Credentials, IStorage, IStorageProvider } from '@lens-protocol/sto
import { InMemoryStorageProvider, createCredentialsStorage } from '@lens-protocol/storage';
import {
ResultAsync,
type TxHash,
errAsync,
invariant,
never,
Expand All @@ -29,16 +30,20 @@ import {
import { type Logger, getLogger } from 'loglevel';

import { type AuthenticatedUser, authenticatedUser } from './AuthenticatedUser';
import { transactionStatus } from './actions';
import { configureContext } from './context';
import {
AuthenticationError,
GraphQLErrorCode,
SigningError,
TransactionIndexingError,
UnauthenticatedError,
UnexpectedError,
hasExtensionCode,
} from './errors';
import { decodeIdToken } from './tokens';
import type { StandardData } from './types';
import { delay } from './utils';

function takeValue<T>({
data,
Expand Down Expand Up @@ -101,30 +106,20 @@ export type LoginParams = ChallengeRequest & {
};

abstract class AbstractClient<TError> {
public readonly context: ClientContext;

protected readonly urql: UrqlClient;

protected readonly logger: Logger;

protected readonly credentials: IStorage<Credentials>;

protected constructor(options: ClientOptions) {
this.context = {
environment: options.environment,
cache: options.cache ?? false,
debug: options.debug ?? false,
origin: options.origin,
storage: options.storage ?? new InMemoryStorageProvider(),
};

this.credentials = createCredentialsStorage(this.context.storage, options.environment.name);
protected constructor(public readonly context: ClientContext) {
this.credentials = createCredentialsStorage(context.storage, context.environment.name);

this.logger = getLogger(this.constructor.name);
this.logger.setLevel(options.debug ? 'DEBUG' : 'SILENT');
this.logger.setLevel(context.debug ? 'DEBUG' : 'SILENT');

this.urql = createClient({
url: options.environment.backend,
url: context.environment.backend,
exchanges: [
mapExchange({
onOperation: async (operation: Operation) => {
Expand Down Expand Up @@ -217,7 +212,7 @@ export class PublicClient extends AbstractClient<UnexpectedError> {
* @returns The new instance of the client.
*/
static create(options: ClientOptions): PublicClient {
return new PublicClient(options);
return new PublicClient(configureContext(options));
}

/**
Expand Down Expand Up @@ -431,6 +426,49 @@ class SessionClient extends AbstractClient<UnauthenticatedError | UnexpectedErro
.map(takeValue);
}

/**
* Given a transaction hash, wait for the transaction to be either confirmed or rejected by the Lens API indexer.
*
* @param hash - The transaction hash to wait for.
* @returns The transaction hash if the transaction was confirmed or an error if the transaction was rejected.
*/
readonly waitForTransaction = (
txHash: TxHash,
): ResultAsync<TxHash, TransactionIndexingError | UnexpectedError> => {
return ResultAsync.fromPromise(this.pollTransactionStatus(txHash), (err) => {
if (err instanceof TransactionIndexingError || err instanceof UnexpectedError) {
return err;
}
return UnexpectedError.from(err);
});
};

protected async pollTransactionStatus(txHash: TxHash): Promise<TxHash> {
const startedAt = Date.now();

while (Date.now() - startedAt < this.context.environment.indexingTimeout) {
const result = await transactionStatus(this, { txHash });

if (result.isErr()) {
throw UnexpectedError.from(result.error);
}

switch (result.value.__typename) {
case 'FinishedTransactionStatus':
return txHash;

case 'FailedTransactionStatus':
throw TransactionIndexingError.from(result.value.reason);

case 'PendingTransactionStatus':
case 'NotIndexedYetStatus':
await delay(this.context.environment.pollingInterval);
break;
}
}
throw TransactionIndexingError.from(`Timeout waiting for transaction ${txHash}`);
}

protected override async fetchOptions(): Promise<RequestInit> {
const base = await super.fetchOptions();
const credentials = (await this.credentials.get()) ?? never('No credentials found');
Expand Down
37 changes: 37 additions & 0 deletions packages/client/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { EnvironmentConfig } from '@lens-protocol/env';
import type { IStorageProvider } from '@lens-protocol/storage';

/**
* The client
*/
export type ClientConfig = {
/**
* The environment configuration to use (e.g. `mainnet`, `testnet`).
*/
environment: EnvironmentConfig;
/**
* Whether to enable caching.
*
* @defaultValue `false`
*/
cache?: boolean;
/**
* Whether to enable debug mode.
*
* @defaultValue `false`
*/
debug?: boolean;
/**
* The URL origin of the client.
*
* Use this to set the `Origin` header for requests from non-browser environments.
*/
origin?: string;

/**
* The storage provider to use.
*
* @defaultValue {@link InMemoryStorageProvider}
*/
storage?: IStorageProvider;
};
27 changes: 27 additions & 0 deletions packages/client/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { EnvironmentConfig } from '@lens-protocol/env';
import { type IStorageProvider, InMemoryStorageProvider } from '@lens-protocol/storage';
import type { ClientConfig } from './config';

/**
* @internal
*/
export type Context = {
environment: EnvironmentConfig;
cache: boolean;
debug: boolean;
origin?: string;
storage: IStorageProvider;
};

/**
* @internal
*/
export function configureContext(from: ClientConfig): Context {
return {
environment: from.environment,
cache: from.cache ?? false,
debug: from.debug ?? false,
origin: from.origin,
storage: from.storage ?? new InMemoryStorageProvider(),
};
}
14 changes: 14 additions & 0 deletions packages/client/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ export class SigningError extends ResultAwareError {
name = 'SigningError' as const;
}

/**
* Error indicating a transaction failed.
*/
export class TransactionError extends ResultAwareError {
name = 'TransactionError' as const;
}

/**
* Error indicating a transaction failed to index.
*/
export class TransactionIndexingError extends ResultAwareError {
name = 'TransactionIndexingError' as const;
}

/**
* Error indicating an operation was not executed due to a validation error.
* See the `cause` property for more information.
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export type { IStorageProvider, InMemoryStorageProvider } from '@lens-protocol/s
export * from '@lens-protocol/types';

export * from './clients';
export * from './config';
export * from './errors';
6 changes: 6 additions & 0 deletions packages/client/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @internal
*/
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
45 changes: 45 additions & 0 deletions packages/client/src/viem/viem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { testnet } from '@lens-protocol/env';
import { describe, expect, it } from 'vitest';

import { chains } from '@lens-network/sdk/viem';
import { evmAddress, uri } from '@lens-protocol/types';
import { http, createWalletClient } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { handleWith } from '.';
import { post } from '../actions/post';
import { PublicClient } from '../clients';

const walletClient = createWalletClient({
account: privateKeyToAccount(import.meta.env.PRIVATE_KEY),
chain: chains.testnet,
transport: http(),
});

const owner = evmAddress(walletClient.account.address);
const app = evmAddress(import.meta.env.TEST_APP);
const account = evmAddress(import.meta.env.TEST_ACCOUNT);

const publicClient = PublicClient.create({
environment: testnet,
origin: 'http://example.com',
});

describe('Given an integration with viem', () => {
describe('When handling transaction actions', () => {
it('Then it should be possible to chain them with other helpers', async () => {
const authenticated = await publicClient.login({
accountOwner: { account, app, owner },
signMessage: (message: string) => walletClient.signMessage({ message }),
});
const sessionClient = authenticated._unsafeUnwrap();

const result = await post(sessionClient, {
contentUri: uri('https://devnet.irys.xyz/3n3Ujg3jPBHX58MPPqYXBSQtPhTgrcTk4RedJgV1Ejhb'),
})
.andThen(handleWith(walletClient))
.andThen(sessionClient.waitForTransaction);

expect(result.isOk(), result.isErr() ? result.error.message : undefined).toBe(true);
});
});
});
10 changes: 10 additions & 0 deletions packages/env/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { url, type URL, never } from '@lens-protocol/types';
export type EnvironmentConfig = {
name: string;
backend: URL;
indexingTimeout: number;
pollingInterval: number;
};

/**
Expand All @@ -17,6 +19,8 @@ export const mainnet: EnvironmentConfig = new Proxy(
{
name: 'mainnet',
backend: url('https://example.com'),
indexingTimeout: 10000,
pollingInterval: 1000,
},
{
get: (_target, _prop) => {
Expand All @@ -33,6 +37,8 @@ export const mainnet: EnvironmentConfig = new Proxy(
export const testnet: EnvironmentConfig = {
name: 'testnet',
backend: url('https://api.testnet.lens.dev/graphql'),
indexingTimeout: 10000,
pollingInterval: 1000,
};

/**
Expand All @@ -41,6 +47,8 @@ export const testnet: EnvironmentConfig = {
export const staging: EnvironmentConfig = {
name: 'staging',
backend: url('https://api.staging.lens.dev/graphql'),
indexingTimeout: 20000,
pollingInterval: 2000,
};

/**
Expand All @@ -49,4 +57,6 @@ export const staging: EnvironmentConfig = {
export const local: EnvironmentConfig = {
name: 'local',
backend: url('http://localhost:3000/graphql'),
indexingTimeout: 5000,
pollingInterval: 500,
};
Loading