Skip to content

Commit

Permalink
Merge pull request #1095 from lens-protocol/cesare/lens-500-sdk-suppo…
Browse files Browse the repository at this point in the history
…rt-operation-approval-workflow

feat: support Operation Approval workflow
  • Loading branch information
cesarenaldi authored Mar 5, 2025
2 parents f9be48b + ed65656 commit cc46962
Show file tree
Hide file tree
Showing 16 changed files with 220 additions and 73 deletions.
4 changes: 2 additions & 2 deletions packages/client/src/actions/accountManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type Account, assertTypename } from '@lens-protocol/graphql';
import * as metadata from '@lens-protocol/metadata';
import { assertOk, never, uri } from '@lens-protocol/types';
import type { SessionClient } from '../clients';
import { chain, loginAsOnboardingUser, storageClient, wallet } from '../test-utils';
import { CHAIN, loginAsOnboardingUser, storageClient, wallet } from '../test-utils';
import { handleOperationWith } from '../viem';
import { createAccountWithUsername, fetchAccount, setAccountMetadata } from './account';
import { fetchMeDetails } from './authentication';
Expand Down Expand Up @@ -53,7 +53,7 @@ describe(`Given the '${createAccountWithUsername.name}' action`, { timeout: 1000
const updated = metadata.account({
name: 'Bruce Wayne',
});
const resource = await storageClient.uploadAsJson(updated, { acl: immutable(chain.id) });
const resource = await storageClient.uploadAsJson(updated, { acl: immutable(CHAIN.id) });
const result = await setAccountMetadata(sessionClient, {
metadataUri: resource.uri,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/actions/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';

import { type Account, Role } from '@lens-protocol/graphql';
import { uri } from '@lens-protocol/types';
import { loginAsOnboardingUser, signer, wallet } from '../test-utils';
import { TEST_SIGNER, loginAsOnboardingUser, wallet } from '../test-utils';
import { handleOperationWith } from '../viem';
import { createAccountWithUsername, fetchAccount } from './account';

Expand Down Expand Up @@ -51,7 +51,7 @@ describe('Given an onboarding user', { timeout: 10000 }, () => {
expect(user).toMatchObject({
role: Role.AccountOwner,
address: newAccount!.address.toLowerCase(),
signer: signer.toLowerCase(),
signer: TEST_SIGNER.toLowerCase(),
});
});
});
Expand Down
32 changes: 32 additions & 0 deletions packages/client/src/authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Operations types.
*/
export enum OperationType {
Post = 'Post',
Repost = 'Repost',
EditPost = 'EditPost',
DeletePost = 'DeletePost',
Follow = 'Follow',
Unfollow = 'Unfollow',
CreateAccount = 'CreateAccount',
CreateUsername = 'CreateUsername',
CreateAndAssignUsername = 'CreateAndAssignUsername',
AssignUsername = 'AssignUsername',
UnassignUsername = 'UnassignUsername',
SetAccountMetadata = 'SetAccountMetadata',
JoinGroup = 'JoinGroup',
LeaveGroup = 'LeaveGroup',
AddGroupMember = 'AddGroupMember',
RemoveGroupMember = 'RemoveGroupMember',
}

/**
* An operation approval request.
*/
export type OperationApprovalRequest = {
nonce: string;
deadline: string;
operation: OperationType;
validator: string;
account: string;
};
50 changes: 25 additions & 25 deletions packages/client/src/clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { currentSession } from './actions';
import { PublicClient } from './clients';
import { GraphQLErrorCode, UnauthenticatedError, UnexpectedError } from './errors';
import {
account,
app,
TEST_ACCOUNT,
TEST_APP,
TEST_SIGNER,
createGraphQLErrorObject,
createPublicClient,
signer,
wallet,
} from './test-utils';
import { delay } from './utils';
Expand All @@ -26,9 +26,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it('Then it should authenticate and stay authenticated', async () => {
const challenge = await client.challenge({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
});
assertOk(challenge);
Expand All @@ -44,8 +44,8 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
assertOk(user);
expect(user.value).toMatchObject({
role: Role.AccountOwner,
address: account.toLowerCase(),
signer: signer.toLowerCase(),
address: TEST_ACCOUNT.toLowerCase(),
signer: TEST_SIGNER.toLowerCase(),
});
});
});
Expand All @@ -54,9 +54,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it('Then it should return an Err<never, SigningError> with any error thrown by the provided `SignMessage` function', async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: async () => {
throw new Error('Test Error');
Expand All @@ -71,9 +71,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it('Then it should return a SessionClient instance associated with the credentials in the storage', async () => {
await client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: signMessageWith(wallet),
});
Expand All @@ -83,8 +83,8 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {

const authentication = await currentSession(authenticated.value);
expect(authentication._unsafeUnwrap()).toMatchObject({
signer,
app,
signer: TEST_SIGNER,
app: TEST_APP,
});
});
});
Expand Down Expand Up @@ -112,9 +112,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it('Then it should revoke the current authenticated session and clear the credentials from the storage', async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: signMessageWith(wallet),
});
Expand Down Expand Up @@ -154,9 +154,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it('Then it should silently refresh credentials and retry the request', async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: signMessageWith(wallet),
});
Expand Down Expand Up @@ -197,9 +197,9 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
it(`Then it should return a '${UnauthenticatedError.name}' to the original request caller`, async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: signMessageWith(wallet),
});
Expand Down
6 changes: 3 additions & 3 deletions packages/client/src/crossRegion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { assertErr, assertOk, never } from '@lens-protocol/types';

import { createAccountWithUsername, fetchAccount, setAccountMetadata } from './actions/account';
import type { SessionClient } from './clients';
import { account, chain, loginAsOnboardingUser, wallet } from './test-utils';
import { CHAIN, TEST_ACCOUNT, loginAsOnboardingUser, wallet } from './test-utils';
import { handleOperationWith } from './viem';

const storageClient = storage.StorageClient.create(storage.staging);
const acl = storage.lensAccountOnly(account, 37111);
const acl = storage.lensAccountOnly(TEST_ACCOUNT, 37111);

describe('Given an instance of the StorageClient (bound to staging)', { timeout: 10000 }, () => {
let initialFileResponse: storage.FileUploadResponse;
Expand Down Expand Up @@ -51,7 +51,7 @@ describe('Given an instance of the StorageClient (bound to staging)', { timeout:
describe('When I upload a file from one region', () => {
it('Then it should be accessible from the Lens API in another region', async () => {
const response = await storageClient.uploadAsJson(updates, {
acl: storage.immutable(chain.id),
acl: storage.immutable(CHAIN.id),
});
const result = await setAccountMetadata(sessionClient, {
metadataUri: response.gatewayUrl,
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/ethers/ethers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('Given an integration with ethers.js', { timeout: 10000 }, () => {
contentUri: uri('https://devnet.irys.xyz/3n3Ujg3jPBHX58MPPqYXBSQtPhTgrcTk4RedJgV1Ejhb'),
})
.andThen(handleOperationWith(wallet))
.andTee(console.log)
.andThen(sessionClient.waitForTransaction),
);

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 @@ -3,6 +3,7 @@ export * from '@lens-protocol/graphql';
export type { IStorageProvider, InMemoryStorageProvider } from '@lens-protocol/storage';
export * from '@lens-protocol/types';
export * from './AuthenticatedUser';
export * from './authorization';
export * from './clients';
export * from './config';
export type * from './context';
Expand Down
38 changes: 18 additions & 20 deletions packages/client/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,17 @@ import { privateKeyToAccount } from 'viem/accounts';
import { ContentWarning, type TextOnlyOptions, textOnly } from '@lens-protocol/metadata';
import { GraphQLErrorCode, PublicClient, testnet } from '.';

const pk = privateKeyToAccount(import.meta.env.PRIVATE_KEY);
export const signer = privateKeyToAccount(import.meta.env.PRIVATE_KEY);

export const chain = chains.testnet;
export const account = evmAddress(import.meta.env.TEST_ACCOUNT);
export const app = evmAddress(import.meta.env.TEST_APP);
export const wallet: WalletClient<Transport, chains.LensNetworkChain, Account> = createWalletClient(
{
account: pk,
chain,
transport: http(),
},
);
export const signer = evmAddress(wallet.account.address);
export const CHAIN = chains.testnet;
export const TEST_ACCOUNT = evmAddress(import.meta.env.TEST_ACCOUNT);
export const TEST_APP = evmAddress(import.meta.env.TEST_APP);
export const wallet: WalletClient<Transport, chains.LensChain, Account> = createWalletClient({
account: signer,
chain: CHAIN,
transport: http(),
});
export const TEST_SIGNER = evmAddress(wallet.account.address);

export function createPublicClient() {
return PublicClient.create({
Expand All @@ -35,11 +33,11 @@ export function loginAsAccountOwner() {
const client = createPublicClient();
return client.login({
accountOwner: {
account,
owner: signer,
app,
account: TEST_ACCOUNT,
owner: TEST_SIGNER,
app: TEST_APP,
},
signMessage: (message) => pk.signMessage({ message }),
signMessage: (message) => signer.signMessage({ message }),
});
}

Expand All @@ -48,10 +46,10 @@ export function loginAsOnboardingUser() {

return client.login({
onboardingUser: {
wallet: signer,
app,
wallet: TEST_SIGNER,
app: TEST_APP,
},
signMessage: (message) => pk.signMessage({ message }),
signMessage: (message) => signer.signMessage({ message }),
});
}

Expand Down Expand Up @@ -86,6 +84,6 @@ export function postOnlyTextMetadata(customMetadata?: TextOnlyOptions) {
locale: 'en-US',
};

return storageClient.uploadAsJson(textOnly(metadata), { acl: immutable(chain.id) });
return storageClient.uploadAsJson(textOnly(metadata), { acl: immutable(CHAIN.id) });
}
export const storageClient = StorageClient.create();
40 changes: 40 additions & 0 deletions packages/client/src/viem/authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { chains } from '@lens-chain/sdk/viem';
import { evmAddress } from '@lens-protocol/types';
import { privateKeyToAccount } from 'viem/accounts';
import { describe, expect, it } from 'vitest';

import { OperationType } from '../authorization';
import { OperationApprovalSigner } from './authorization';

const privateKey = '0xa7d25f98c7996df6418d5205d03386b254451d45de060dcd4c7f486d9c12061e';
const appAddress = evmAddress('0x3a24d26AdEBA0d6330F207d0ca699cBE5fFbE553');
const accountAddress = '0xbca85dda68cC21B98F6a416c28F9de94C4cBdcB9';
const lensPrimitive = '0x07753ab956B70498196772E8421379DB12de54eb';

describe(
`Given an instance of the '${OperationApprovalSigner.name}' for viem`,
{ timeout: 10000 },
() => {
describe('When signing an OperationApprovalRequest', () => {
it('Then it should return the expected signature', async () => {
const approver = new OperationApprovalSigner({
app: appAddress,
chain: chains.testnet,
signer: privateKeyToAccount(privateKey),
});

const signature = await approver.signOperationApproval({
nonce: '42',
deadline: '1630000000',
operation: OperationType.Post,
validator: lensPrimitive,
account: accountAddress,
});

expect(signature).toEqual(
'0xc0dc1300e351b79ba63b17581c5d5dd914ed2d6ddcd7cd4476b3930c199fd75807adc8c989db3da1af0ceb66689d0d3954f7180a81cfcb3e77af1bdcb6508d111c',
);
});
});
},
);
57 changes: 57 additions & 0 deletions packages/client/src/viem/authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { chains } from '@lens-chain/sdk/viem';

import { type EvmAddress, evmAddress } from '@lens-protocol/types';
import { type TypedDataDefinition, checksumAddress } from 'viem';
import type { OperationApprovalRequest } from '../authorization';

export type TypedDataSigner = {
signTypedData(args: TypedDataDefinition): Promise<string>;
};

export type LocalOperationApprovalSignerContext = {
app: EvmAddress;
signer: TypedDataSigner;
chain: chains.LensChain;
};

export const DOMAIN_NAME = 'Lens Source';
export const DOMAIN_VERSION = '1';

/**
*
*/
export class OperationApprovalSigner {
constructor(private readonly context: LocalOperationApprovalSignerContext) {}

async signOperationApproval(data: OperationApprovalRequest): Promise<string> {
return this.context.signer.signTypedData(this.createTypedDataDefinition(data));
}

private createTypedDataDefinition(data: OperationApprovalRequest): TypedDataDefinition {
return {
domain: {
name: DOMAIN_NAME,
version: DOMAIN_VERSION,
chainId: this.context.chain.id,
verifyingContract: checksumAddress(this.context.app),
},
types: {
SourceStamp: [
{ name: 'source', type: 'address' },
{ name: 'originalMsgSender', type: 'address' },
{ name: 'validator', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'SourceStamp',
message: {
source: checksumAddress(this.context.app),
originalMsgSender: checksumAddress(evmAddress(data.account)),
validator: checksumAddress(evmAddress(data.validator)),
nonce: data.nonce,
deadline: data.deadline,
},
};
}
}
1 change: 1 addition & 0 deletions packages/client/src/viem/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './authorization';
export * from './signer';
Loading

0 comments on commit cc46962

Please sign in to comment.