Skip to content

Commit

Permalink
feat: allow decryptable content with wallet-only auth (#784)
Browse files Browse the repository at this point in the history
* feat: allow decryptable content with wallet-only auth

* fix: reinstate retries

* chore: add changeset

* refactor: rename

* fix: tests
  • Loading branch information
reecejohnson authored Dec 20, 2023
1 parent d3a85d1 commit 61f0aed
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 41 deletions.
5 changes: 5 additions & 0 deletions .changeset/rude-suns-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lens-protocol/client": minor
---

feat: allow decryptable content with wallet-only auth
4 changes: 2 additions & 2 deletions packages/client/src/__helpers__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ export async function createOrGetProfile(signer: Wallet, client: LensClient, han
return result ?? never('Profile not found');
}

export async function authenticate(signer: Wallet, client: LensClient, profile: ProfileFragment) {
export async function authenticate(signer: Wallet, client: LensClient, profile?: ProfileFragment) {
const { id, text } = await client.authentication.generateChallenge({
signedBy: signer.address,
for: profile.id,
for: profile?.id,
});

const signature = await signer.signMessage(text);
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/authentication/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@ export class Authentication implements IAuthentication {
return handler(profileId);
}

async requireAtLeastWalletAuthentication<
TResult extends Result<TValue, TError>,
TValue,
TError extends IEquatableError,
>(
handler: (profileId: string | null) => Promise<TResult>,
): PromiseResult<TValue, TError | CredentialsExpiredError | NotAuthenticatedError> {
const result = await this.getCredentials();

if (result.isFailure()) {
return result;
}

return handler(result.value.getProfileId());
}

async getRequestHeader(): PromiseResult<
Record<string, string>,
CredentialsExpiredError | NotAuthenticatedError
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/gated/Gated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ export class Gated {
async decryptPublicationMetadataFragment<T extends AnyEncryptablePublicationMetadataFragment>(
encryptedMetadata: T,
): PromiseResult<T, CannotDecryptError | CredentialsExpiredError | NotAuthenticatedError> {
return this.authentication.requireAuthentication(async (profileId) => {
return this.authentication.requireAtLeastWalletAuthentication(async (profileId) => {
const result = await this.client.decryptPublicationMetadataFragment(encryptedMetadata, {
profileId,
profileId: profileId ?? undefined,
});

if (result.isSuccess()) {
Expand Down
151 changes: 114 additions & 37 deletions packages/client/src/gated/__tests__/LensClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
import { isEncryptedPublicationMetadata } from '@lens-protocol/gated-content';
import { CannotDecryptError, isEncryptedPublicationMetadata } from '@lens-protocol/gated-content';
import * as metadata from '@lens-protocol/metadata';
import { invariant } from '@lens-protocol/shared-kernel';
import { Wallet } from 'ethers';
Expand All @@ -11,72 +11,149 @@ import {
enableLensProfileManager,
postOnchainViaLensManager,
} from '../../__helpers__/setup';
import { ProfileFragment } from '../../graphql';
import { PostFragment } from '../../graphql';
import { LensClient } from '../LensClient';
import { createGatedLensClient } from '../__helpers/setup';

jest.retryTimes(3, { logErrorsBeforeRetry: true });

const signer = new Wallet('0xd6e6257e8cf0f321ad0f798dd0b121a7eb4fe9c7c51994e843c0a1ed05867a5f');

const signerWithNoProfile = new Wallet(
'dc377a505ab51735b73656ddfd5abc01fb9d26544b71d9188ecd74c70a22cb6d',
);

describe(`Given an instance of "gated.${LensClient.name}"`, () => {
const client = createGatedLensClient(signer);
let profile: ProfileFragment;
const initialPostMetadata = metadata.image({
image: {
item: faker.internet.url(),
type: metadata.MediaImageMimeType.JPEG,
},
content: metadata.toMarkdown(faker.lorem.sentence()),
hideFromFeed: true,
});
const publicationAuthorHandle = 'nandos2';

beforeAll(async () => {
profile = await createOrGetProfile(signer, client, 'nandos2');
describe('and a token-gated post', () => {
let post: PostFragment;

await authenticate(signer, client, profile);
beforeAll(async () => {
const client = createGatedLensClient(signer);
const profile = await createOrGetProfile(signer, client, publicationAuthorHandle);

await enableLensProfileManager(signer, client, profile);
}, 30_000);
await authenticate(signer, client, profile);

describe(`when testing encryption and decryption end-to-end`, () => {
const initial = metadata.image({
image: {
item: faker.internet.url(),
type: metadata.MediaImageMimeType.JPEG,
},
content: metadata.toMarkdown(faker.lorem.sentence()),
hideFromFeed: true,
});
await enableLensProfileManager(signer, client, profile);

it('should be decryptable to the publication author', async () => {
const condition = metadata.eoaOwnershipCondition({
address: Wallet.createRandom().address,
const condition = metadata.profileOwnershipCondition({
profileId: profile.id,
});
const encrypted = await client.gated.encryptPublicationMetadata(initial, condition);

const post = await postOnchainViaLensManager(signer, client, encrypted.unwrap());

invariant(
isEncryptedPublicationMetadata(post.metadata),
'Metadata is not encrypted. This is likely an API issue.',
const encrypted = await client.gated.encryptPublicationMetadata(
initialPostMetadata,
condition,
);

const decrypted = await client.gated.decryptPublicationMetadataFragment(post.metadata);
post = await postOnchainViaLensManager(signer, client, encrypted.unwrap());
}, 60_000);

describe('when decrypted by the publication author', () => {
it('should return the decrypted metadata', async () => {
const client = createGatedLensClient(signer);
const profile = await createOrGetProfile(signer, client, publicationAuthorHandle);

await authenticate(signer, client, profile);

invariant(
isEncryptedPublicationMetadata(post.metadata),
'Metadata is not encrypted. This is likely an API issue.',
);

const decrypted = await client.gated.decryptPublicationMetadataFragment(post.metadata);

expect(decrypted.unwrap()).toMatchObject({
asset: {
image: {
raw: {
uri: initialPostMetadata.lens.image.item,
},
},
},
content: initialPostMetadata.lens.content,
});
}, 60_000);
});

describe('when decrypted by just a wallet that meets the token-gated conditions', () => {
it('should return the decrypted metadata', async () => {
const authenticatedWithOnlyWalletClient = createGatedLensClient(signer);

await authenticate(signer, authenticatedWithOnlyWalletClient);

expect(decrypted.unwrap()).toMatchObject({
asset: {
image: {
raw: {
uri: initial.lens.image.item,
invariant(
isEncryptedPublicationMetadata(post.metadata),
'Metadata is not encrypted. This is likely an API issue.',
);

const decrypted =
await authenticatedWithOnlyWalletClient.gated.decryptPublicationMetadataFragment(
post.metadata,
);

expect(decrypted.unwrap()).toMatchObject({
asset: {
image: {
raw: {
uri: initialPostMetadata.lens.image.item,
},
},
},
},
content: initial.lens.content,
content: initialPostMetadata.lens.content,
});
});
}, 60_000);
});

describe('when decrypted by just a wallet that does not meet the token-gated conditions', () => {
it(`should throw a ${CannotDecryptError.name} error`, async () => {
const authenticatedWithOnlyWalletClient = createGatedLensClient(signerWithNoProfile);

await authenticate(signer, authenticatedWithOnlyWalletClient);

invariant(
isEncryptedPublicationMetadata(post.metadata),
'Metadata is not encrypted. This is likely an API issue.',
);

const decryptedResult =
await authenticatedWithOnlyWalletClient.gated.decryptPublicationMetadataFragment(
post.metadata,
);

const isFailure = decryptedResult.isFailure();

invariant(isFailure && decryptedResult.error, 'Expected decryption to fail with an error');

expect(decryptedResult.error).toBeInstanceOf(CannotDecryptError);
});
});
});

describe('and a token-gated post with collect conditions', () => {
// TODO complete once collect is fixed at the API level
it.skip('should be decryptable via the collect condition', async () => {
const client = createGatedLensClient(signer);
const profile = await createOrGetProfile(signer, client, publicationAuthorHandle);

const condition = metadata.collectCondition({
publicationId: await client.publication.predictNextOnChainPublicationId({
from: profile.id,
}),
thisPublication: true,
});
const encrypted = await client.gated.encryptPublicationMetadata(initial, condition);
const encrypted = await client.gated.encryptPublicationMetadata(
initialPostMetadata,
condition,
);

const post = await postOnchainViaLensManager(signer, client, encrypted.unwrap());

Expand Down

0 comments on commit 61f0aed

Please sign in to comment.