diff --git a/packages/client/src/clients.ts b/packages/client/src/clients.ts index d7e37a4fa..146d6b910 100644 --- a/packages/client/src/clients.ts +++ b/packages/client/src/clients.ts @@ -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, @@ -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({ data, @@ -101,30 +106,20 @@ export type LoginParams = ChallengeRequest & { }; abstract class AbstractClient { - public readonly context: ClientContext; - protected readonly urql: UrqlClient; protected readonly logger: Logger; protected readonly credentials: IStorage; - 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) => { @@ -217,7 +212,7 @@ export class PublicClient extends AbstractClient { * @returns The new instance of the client. */ static create(options: ClientOptions): PublicClient { - return new PublicClient(options); + return new PublicClient(configureContext(options)); } /** @@ -431,6 +426,49 @@ class SessionClient extends AbstractClient => { + 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 { + 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 { const base = await super.fetchOptions(); const credentials = (await this.credentials.get()) ?? never('No credentials found'); diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts new file mode 100644 index 000000000..76f87bde9 --- /dev/null +++ b/packages/client/src/config.ts @@ -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; +}; diff --git a/packages/client/src/context.ts b/packages/client/src/context.ts new file mode 100644 index 000000000..995d799f3 --- /dev/null +++ b/packages/client/src/context.ts @@ -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(), + }; +} diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index a3ac618a7..039d5bdf7 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -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. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b21d1d145..753a51112 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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'; diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts new file mode 100644 index 000000000..768b79cfd --- /dev/null +++ b/packages/client/src/utils.ts @@ -0,0 +1,6 @@ +/** + * @internal + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/client/src/viem/viem.test.ts b/packages/client/src/viem/viem.test.ts new file mode 100644 index 000000000..e7c2480e7 --- /dev/null +++ b/packages/client/src/viem/viem.test.ts @@ -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); + }); + }); +}); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 7b4d2afde..42d322995 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -6,6 +6,8 @@ import { url, type URL, never } from '@lens-protocol/types'; export type EnvironmentConfig = { name: string; backend: URL; + indexingTimeout: number; + pollingInterval: number; }; /** @@ -17,6 +19,8 @@ export const mainnet: EnvironmentConfig = new Proxy( { name: 'mainnet', backend: url('https://example.com'), + indexingTimeout: 10000, + pollingInterval: 1000, }, { get: (_target, _prop) => { @@ -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, }; /** @@ -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, }; /** @@ -49,4 +57,6 @@ export const staging: EnvironmentConfig = { export const local: EnvironmentConfig = { name: 'local', backend: url('http://localhost:3000/graphql'), + indexingTimeout: 5000, + pollingInterval: 500, };