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: Turnkey Passkey POC #1481

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/build-app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
echo "EXPO_PUBLIC_PRIVY_APP_ID=${{ secrets.EXPO_PUBLIC_PRIVY_APP_ID }}" >> .env.production
echo "EXPO_PUBLIC_EVM_RPC_ENDPOINT=${{ secrets.EXPO_PUBLIC_EVM_RPC_ENDPOINT }}" >> .env.production
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.production
echo "EXPO_PUBLIC_TURNKEY_ORG=${{ secrets.EXPO_PUBLIC_TURNKEY_ORG }}" >> .env.production

- name: Update EAS config with env variables
run: node scripts/Øbuild/eas.js --env production
Expand Down Expand Up @@ -91,7 +92,7 @@ jobs:
echo "EXPO_PUBLIC_PRIVY_APP_ID=${{ secrets.EXPO_PUBLIC_PRIVY_APP_ID }}" >> .env.production
echo "EXPO_PUBLIC_EVM_RPC_ENDPOINT=${{ secrets.EXPO_PUBLIC_EVM_RPC_ENDPOINT }}" >> .env.production
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.production

echo "EXPO_PUBLIC_TURNKEY_ORG=${{ secrets.EXPO_PUBLIC_TURNKEY_ORG }}" >> .env.production
- name: Update EAS config with env variables
run: node scripts/build/eas.js --env production

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ jobs:
echo "EXPO_PUBLIC_PRIVY_APP_ID=${{ secrets.EXPO_PUBLIC_PRIVY_APP_ID }}" >> $env_file
echo "EXPO_PUBLIC_EVM_RPC_ENDPOINT=${{ secrets.EXPO_PUBLIC_EVM_RPC_ENDPOINT }}" >> $env_file
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> $env_file
echo "EXPO_PUBLIC_TURNKEY_ORG=${{ secrets.EXPO_PUBLIC_TURNKEY_ORG }}" >> $env_file

- name: Update EAS config with env variables
run: |
Expand Down
4 changes: 2 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"newArchEnabled": false,
"version": "2.1.0",
"ios": {
"buildNumber": "58"
"buildNumber": "65"
},
"android": {
"versionCode": 257
"versionCode": 265
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ import TableView, { TableViewItemType } from "../../TableView/TableView";
import { TableViewEmoji, TableViewImage } from "../../TableView/TableViewImage";
import { RightViewChevron } from "../../TableView/TableViewRightChevron";

export function getConnectViaWalletTableViewPasskeyItem(
args: Partial<TableViewItemType>
): TableViewItemType {
return {
id: "passkey",
leftView: <TableViewEmoji emoji="🔑" />,
title: translate("walletSelector.converseAccount.connectViaPasskey"),
rightView: <RightViewChevron />,
...args,
};
}

export function getConnectViaWalletTableViewPrivateKeyItem(
args: Partial<TableViewItemType>
): TableViewItemType {
Expand Down
41 changes: 39 additions & 2 deletions components/Onboarding/init-xmtp-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Signer } from "ethers";
import { Alert } from "react-native";

// import { invalidateProfileSocialsQuery } from "../../data/helpers/profiles/profilesUpdate";
Expand All @@ -13,8 +12,13 @@ import { awaitableAlert } from "../../utils/alert";
import logger from "../../utils/logger";
import { logoutAccount, waitForLogoutTasksDone } from "../../utils/logout";
import { sentryTrackMessage } from "../../utils/sentry";
import { createXmtpClientFromSigner } from "../../utils/xmtpRN/signIn";
import {
createXmtpClientFromSigner,
createXmtpClientFromViemAccount,
} from "../../utils/xmtpRN/signIn";
import { getXmtpClient } from "../../utils/xmtpRN/sync";
import { Signer } from "ethers";
import { LocalAccount } from "viem/accounts";

export async function initXmtpClient(args: {
signer: Signer;
Expand Down Expand Up @@ -49,6 +53,39 @@ export async function initXmtpClient(args: {
}
}

export async function initXmtpClientFromViemAccount(args: {
account: LocalAccount;
address: string;
privyAccountId?: string;
isEphemeral?: boolean;
pkPath?: string;
}) {
const { account, address, ...restArgs } = args;

if (!account || !address) {
throw new Error("No signer or address");
}

Comment on lines +65 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix incorrect error message

The error message refers to "signer" but the function checks for "account".

Apply this diff to fix the error message:

  if (!account || !address) {
-   throw new Error("No signer or address");
+   throw new Error("No account or address");
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!account || !address) {
throw new Error("No signer or address");
}
if (!account || !address) {
throw new Error("No account or address");
}

try {
await createXmtpClientFromViemAccount(account, async () => {
await awaitableAlert(
translate("current_installation_revoked"),
translate("current_installation_revoked_description")
);
throw new Error("Current installation revoked");
});

await connectWithAddress({
address,
...restArgs,
});
} catch (e) {
await logoutAccount(address, false, true, () => {});
logger.error(e);
throw e;
}
}

Comment on lines +56 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reduce code duplication and improve error handling

The initXmtpClientFromViemAccount function largely duplicates initXmtpClient. Consider refactoring to reduce duplication and improve error handling.

+ type BaseInitArgs = {
+   address: string;
+   privyAccountId?: string;
+   isEphemeral?: boolean;
+   pkPath?: string;
+ };
+ 
+ type InitXmtpArgs = BaseInitArgs & {
+   signer?: Signer;
+   account?: LocalAccount;
+ };
+ 
+ async function initXmtpBase(
+   createClient: () => Promise<void>,
+   args: BaseInitArgs
+ ) {
+   try {
+     await createClient();
+     await connectWithAddress(args);
+   } catch (e) {
+     await logoutAccount(args.address, false, true, () => {});
+     logger.error(e);
+     throw e;
+   }
+ }
+ 
- export async function initXmtpClientFromViemAccount(args: {
-   account: LocalAccount;
-   address: string;
-   privyAccountId?: string;
-   isEphemeral?: boolean;
-   pkPath?: string;
- }) {
+ export async function initXmtpClientFromViemAccount(
+   args: InitXmtpArgs & Required<Pick<InitXmtpArgs, "account">>
+ ) {
    const { account, address, ...restArgs } = args;
    if (!account || !address) {
      throw new Error("No account or address");
    }
-   try {
-     await createXmtpClientFromViemAccount(account, async () => {
+   return initXmtpBase(
+     () => createXmtpClientFromViemAccount(account, async () => {
        await awaitableAlert(
          translate("current_installation_revoked"),
          translate("current_installation_revoked_description")
        );
        throw new Error("Current installation revoked");
-     });
-     await connectWithAddress({
+     }),
+     {
        address,
        ...restArgs,
-     });
-   } catch (e) {
-     await logoutAccount(address, false, true, () => {});
-     logger.error(e);
-     throw e;
-   }
+     }
+   );
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function initXmtpClientFromViemAccount(args: {
account: LocalAccount;
address: string;
privyAccountId?: string;
isEphemeral?: boolean;
pkPath?: string;
}) {
const { account, address, ...restArgs } = args;
if (!account || !address) {
throw new Error("No signer or address");
}
try {
await createXmtpClientFromViemAccount(account, async () => {
await awaitableAlert(
translate("current_installation_revoked"),
translate("current_installation_revoked_description")
);
throw new Error("Current installation revoked");
});
await connectWithAddress({
address,
...restArgs,
});
} catch (e) {
await logoutAccount(address, false, true, () => {});
logger.error(e);
throw e;
}
}
type BaseInitArgs = {
address: string;
privyAccountId?: string;
isEphemeral?: boolean;
pkPath?: string;
};
type InitXmtpArgs = BaseInitArgs & {
signer?: Signer;
account?: LocalAccount;
};
async function initXmtpBase(
createClient: () => Promise<void>,
args: BaseInitArgs
) {
try {
await createClient();
await connectWithAddress(args);
} catch (e) {
await logoutAccount(args.address, false, true, () => {});
logger.error(e);
throw e;
}
}
export async function initXmtpClientFromViemAccount(
args: InitXmtpArgs & Required<Pick<InitXmtpArgs, "account">>
) {
const { account, address, ...restArgs } = args;
if (!account || !address) {
throw new Error("No account or address");
}
return initXmtpBase(
() => createXmtpClientFromViemAccount(account, async () => {
await awaitableAlert(
translate("current_installation_revoked"),
translate("current_installation_revoked_description")
);
throw new Error("Current installation revoked");
}),
{
address,
...restArgs,
}
);
}

type IBaseArgs = {
address: string;
};
Expand Down
4 changes: 4 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const defaultConfig = {
},
splitScreenThreshold: 600,
framesAllowedSchemes: ["http", "https", "ethereum"],
turnkeyOrg: process.env.EXPO_PUBLIC_TURNKEY_ORG,
};

const isAndroid = Platform.OS === "android";
Expand All @@ -76,6 +77,7 @@ const ENV = {
appCheckDebugToken: isAndroid
? process.env.EXPO_PUBLIC_FIREBASE_APP_CHECK_DEBUG_TOKEN_ANDROID
: process.env.EXPO_PUBLIC_FIREBASE_APP_CHECK_DEBUG_TOKEN_IOS,
appDomain: "dev.converse.xyz",
},
preview: {
...defaultConfig,
Expand All @@ -95,6 +97,7 @@ const ENV = {
alphaGroupChatUrl:
"https://converse.xyz/group-invite/eQAvo-WvwrdBTsHINuSMJ",
appCheckDebugToken: undefined,
appDomain: "preview.converse.xyz",
},
prod: {
...defaultConfig,
Expand Down Expand Up @@ -129,6 +132,7 @@ const ENV = {
alphaGroupChatUrl:
"https://converse.xyz/group-invite/eQAvo-WvwrdBTsHINuSMJ",
appCheckDebugToken: undefined,
appDomain: "converse.xyz",
},
} as const;

Expand Down
86 changes: 86 additions & 0 deletions features/onboarding/passkey/passkeyAuthStore.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn’t proof of concept related, but I do not like the pattern of unnecessarily putting exhaust stand into context. Not sure why we would do this.

Are there any benefits?

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createContext, memo, useContext, useRef } from "react";
import { createStore, useStore } from "zustand";
import { LocalAccount } from "viem/accounts";
import { TurnkeyStoreInfo } from "@/utils/passkeys/passkeys.interfaces";

type IPasskeyAuthStoreProps = {
loading?: boolean;
error?: string;
statusString?: string;
account?: LocalAccount;
turnkeyInfo?: TurnkeyStoreInfo;
previousPasskeyName?: string;
};

type IPasskeyAuthStoreState = IPasskeyAuthStoreProps & {
setLoading: (loading: boolean) => void;
setError: (error: string | undefined) => void;
setStatusString: (statusString: string | undefined) => void;
setAccount: (account: LocalAccount | undefined) => void;
setTurnkeyInfo: (turnkeyInfo: TurnkeyStoreInfo | undefined) => void;
setPreviousPasskeyName: (previousPasskeyName: string | undefined) => void;
reset: () => void;
};

type IPasskeyAuthStoreProviderProps =
React.PropsWithChildren<IPasskeyAuthStoreProps>;

type IPasskeyAuthStore = ReturnType<typeof createPasskeyAuthStore>;

const PasskeyAuthStoreContext = createContext<IPasskeyAuthStore | null>(null);

export const PasskeyAuthStoreProvider = memo(
({ children, ...props }: IPasskeyAuthStoreProviderProps) => {
const storeRef = useRef<IPasskeyAuthStore>();
if (!storeRef.current) {
storeRef.current = createPasskeyAuthStore(props);
}
return (
<PasskeyAuthStoreContext.Provider value={storeRef.current}>
{children}
</PasskeyAuthStoreContext.Provider>
);
}
);

const createPasskeyAuthStore = (initProps: IPasskeyAuthStoreProps) => {
const DEFAULT_PROPS: IPasskeyAuthStoreProps = {
loading: false,
error: undefined,
statusString: undefined,
account: undefined,
turnkeyInfo: undefined,
previousPasskeyName: undefined,
};
return createStore<IPasskeyAuthStoreState>()((set) => ({
...DEFAULT_PROPS,
...initProps,
setLoading: (loading) =>
loading ? set({ loading, error: undefined }) : set({ loading: false }),
setError: (error) => set({ error, statusString: undefined }),
setStatusString: (statusString) => set({ statusString }),
setAccount: (account) => set({ account }),
setTurnkeyInfo: (turnkeyInfo) => set({ turnkeyInfo }),
setPreviousPasskeyName: (previousPasskeyName) =>
set({ previousPasskeyName }),
reset: () => set(DEFAULT_PROPS),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure consistent state reset

The reset function uses DEFAULT_PROPS but doesn't account for any additional properties set during initialization.

-    reset: () => set(DEFAULT_PROPS),
+    reset: () => set({ ...DEFAULT_PROPS, ...initProps }),

Committable suggestion skipped: line range outside the PR's diff.

}));
Comment on lines +55 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add state update validation.

The state setters don't validate input, which could lead to inconsistent state.

Add validation to prevent invalid state updates:

 setTurnkeyInfo: (turnkeyInfo) => 
-  set({ turnkeyInfo }),
+  set((state) => {
+    if (turnkeyInfo && (!turnkeyInfo.subOrganizationId || !turnkeyInfo.walletId)) {
+      console.warn('Invalid turnkey info provided');
+      return state;
+    }
+    return { turnkeyInfo };
+  }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return createStore<IPasskeyAuthStoreState>()((set) => ({
...DEFAULT_PROPS,
...initProps,
setLoading: (loading) =>
loading ? set({ loading, error: undefined }) : set({ loading: false }),
setError: (error) => set({ error, statusString: undefined }),
setStatusString: (statusString) => set({ statusString }),
setAccount: (account) => set({ account }),
setTurnkeyInfo: (turnkeyInfo) => set({ turnkeyInfo }),
setPreviousPasskeyName: (previousPasskeyName) =>
set({ previousPasskeyName }),
reset: () => set(DEFAULT_PROPS),
}));
return createStore<IPasskeyAuthStoreState>()((set) => ({
...DEFAULT_PROPS,
...initProps,
setLoading: (loading) =>
loading ? set({ loading, error: undefined }) : set({ loading: false }),
setError: (error) => set({ error, statusString: undefined }),
setStatusString: (statusString) => set({ statusString }),
setAccount: (account) => set({ account }),
setTurnkeyInfo: (turnkeyInfo) =>
set((state) => {
if (turnkeyInfo && (!turnkeyInfo.subOrganizationId || !turnkeyInfo.walletId)) {
console.warn('Invalid turnkey info provided');
return state;
}
return { turnkeyInfo };
}),
setPreviousPasskeyName: (previousPasskeyName) =>
set({ previousPasskeyName }),
reset: () => set(DEFAULT_PROPS),
}));

};

export function usePasskeyAuthStoreContext<T>(
selector: (state: IPasskeyAuthStoreState) => T
): T {
const store = useContext(PasskeyAuthStoreContext);
if (!store) throw new Error("Missing PasskeyAuthStore.Provider in the tree");
return useStore(store, selector);
}

export const usePasskeyAuthStore = () => {
const store = useContext(PasskeyAuthStoreContext);
if (!store) {
throw new Error(
"usePasskeyAuthStore must be used within a PasskeyAuthStoreProvider"
);
}
return store;
};
7 changes: 7 additions & 0 deletions i18n/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const en = {
title: "CONVERSE ACCOUNTS",
connectViaPhone: "Connect via Phone",
createEphemeral: "Create Ephemeral Account",
connectViaPasskey: "Connect via Passkey",
},
installedApps: {
title: "INSTALLED APPS",
Expand Down Expand Up @@ -51,6 +52,12 @@ export const en = {
},
resendCodeIn: "Resend code in {{seconds}} seconds...",
},
passkey: {
title: "Create Passkey",
add_account_title: "Add account by passkey",
subtitle: "Create a passkey to connect to Converse",
createButton: "Create Passkey",
},
Comment on lines +55 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add missing error messages for passkey operations.

The passkey translations should include error messages for common failure scenarios.

Add these translations:

 passkey: {
   title: "Create Passkey",
   add_account_title: "Add account by passkey",
   subtitle: "Create a passkey to connect to Converse",
   createButton: "Create Passkey",
+  errors: {
+    creation_failed: "Failed to create passkey",
+    login_failed: "Failed to login with passkey",
+    invalid_name: "Invalid passkey name",
+    already_exists: "A passkey with this name already exists",
+  },
 },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
passkey: {
title: "Create Passkey",
add_account_title: "Add account by passkey",
subtitle: "Create a passkey to connect to Converse",
createButton: "Create Passkey",
},
passkey: {
title: "Create Passkey",
add_account_title: "Add account by passkey",
subtitle: "Create a passkey to connect to Converse",
createButton: "Create Passkey",
errors: {
creation_failed: "Failed to create passkey",
login_failed: "Failed to login with passkey",
invalid_name: "Invalid passkey name",
already_exists: "A passkey with this name already exists",
},
},

createEphemeral: {
title: "Create an ephemeral account",
subtitle:
Expand Down
1 change: 1 addition & 0 deletions ios/Converse/Converse.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<array>
<string>applinks:dev.converse.xyz</string>
<string>applinks:dev.getconverse.app</string>
<string>webcredentials:dev.converse.xyz</string>
</array>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>production</string>
Expand Down
16 changes: 13 additions & 3 deletions ios/ConverseNotificationExtension/Spam.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func computeSpamScoreV3Welcome(client: XMTP.Client, conversation: XMTP.Conversat
return 0
}

func computeSpamScoreV3Message(client: XMTP.Client, conversation: XMTP.Conversation, decodedMessage: DecodedMessage, apiURI: String?) async -> Double {
func computeSpamScoreV3Message(client: XMTP.Client, conversation: XMTP.Conversation, decodedMessage: Message, apiURI: String?) async -> Double {
do {

// try await client.preferences.syncConsent()
Expand Down Expand Up @@ -209,9 +209,19 @@ func computeSpamScoreV3Message(client: XMTP.Client, conversation: XMTP.Conversat
//
sentryTrackError(error: error, extras: ["message": "Failed to compute Spam Score for V3 Message"])
}
let contentType = getContentTypeString(type: decodedMessage.encodedContent.type)

guard let messageContentType = try? decodedMessage.encodedContent.type else {
return 1
}
let contentType = getContentTypeString(type: messageContentType)


let messageContent = String(data: decodedMessage.encodedContent.content, encoding: .utf8)
guard let content = try? decodedMessage.encodedContent.content else {
return 1
}

let messageContent = String(data:content, encoding: .utf8)

let messageSpamScore = getMessageSpamScore(message: messageContent, contentType: contentType)

return messageSpamScore
Expand Down
Loading
Loading