Skip to content

Commit d620019

Browse files
committed
⚗️ app: panda signature test UI
1 parent 03d2bc7 commit d620019

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

src/components/pay-mode/PaySelector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export default function PaySelector() {
7272
},
7373
onSettled: async (data) => {
7474
await queryClient.invalidateQueries({ queryKey: ["card", "details"] });
75-
if (data && "mode" in data && data.mode > 0) {
75+
if (data && "mode" in data && typeof data.mode === "number" && data.mode > 0) {
7676
queryClient.setQueryData(["settings", "installments"], data.mode);
7777
}
7878
},

src/components/settings/Settings.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ import { Alert, Pressable } from "react-native";
55
import { setStringAsync } from "expo-clipboard";
66
import { useRouter } from "expo-router";
77

8-
import { ArrowLeft, Check, HelpCircle, LogOut, SendHorizontal } from "@tamagui/lucide-icons";
8+
import {
9+
ArrowLeft,
10+
Check,
11+
FileSignature,
12+
Fingerprint,
13+
HelpCircle,
14+
LogOut,
15+
SendHorizontal,
16+
} from "@tamagui/lucide-icons";
917
import { ScrollView, Separator, XStack } from "tamagui";
1018

19+
import { useMutation } from "@tanstack/react-query";
1120
import { useDisconnect } from "wagmi";
1221

1322
import release from "../../generated/release";
@@ -16,6 +25,7 @@ import { logout as logoutIntercom, present } from "../../utils/intercom";
1625
import { logout as logoutOnesignal } from "../../utils/onesignal";
1726
import queryClient from "../../utils/queryClient";
1827
import reportError from "../../utils/reportError";
28+
import { signCard, signCardWebauthn } from "../../utils/server";
1929
import useAccount from "../../utils/useAccount";
2030
import SafeView from "../shared/SafeView";
2131
import Text from "../shared/Text";
@@ -27,6 +37,28 @@ export default function Settings() {
2737
const { t } = useTranslation();
2838
const { mutate: disconnectAccount } = useDisconnect();
2939
const { mutate: submitCoverage, isSuccess: coverageSuccess, isError: coverageError } = useSubmitCoverage();
40+
const {
41+
mutate: sign,
42+
isPending: signingPending,
43+
isSuccess: signingSuccess,
44+
isError: signingError,
45+
} = useMutation({
46+
mutationFn: () => signCard(),
47+
onError: (error) => {
48+
reportError(error);
49+
},
50+
});
51+
const {
52+
mutate: signWebauthn,
53+
isPending: signWebauthnPending,
54+
isSuccess: signWebauthnSuccess,
55+
isError: signWebauthnError,
56+
} = useMutation({
57+
mutationFn: () => signCardWebauthn(),
58+
onError: (error) => {
59+
reportError(error);
60+
},
61+
});
3062
return (
3163
<SafeView fullScreen tab>
3264
<View fullScreen padded gap="$s5">
@@ -68,6 +100,46 @@ export default function Settings() {
68100
</XStack>
69101
</Pressable>
70102
<Separator borderColor="$borderNeutralSoft" />
103+
<Pressable
104+
onPress={() => {
105+
if (!signingPending) sign();
106+
}}
107+
>
108+
<XStack justifyContent="space-between" alignItems="center" padding="$s4">
109+
<XStack justifyContent="space-between" flex={1}>
110+
<XStack gap="$s3" justifyContent="flex-start" alignItems="center">
111+
<FileSignature color="$backgroundBrand" />
112+
<Text subHeadline color="$uiNeutralPrimary">
113+
{t("Signature")}
114+
</Text>
115+
</XStack>
116+
{(signingSuccess || signingError) && (
117+
<Check color={signingSuccess ? "$backgroundBrand" : "$interactiveBaseErrorDefault"} />
118+
)}
119+
</XStack>
120+
</XStack>
121+
</Pressable>
122+
<Separator borderColor="$borderNeutralSoft" />
123+
<Pressable
124+
onPress={() => {
125+
if (!signWebauthnPending) signWebauthn();
126+
}}
127+
>
128+
<XStack justifyContent="space-between" alignItems="center" padding="$s4">
129+
<XStack justifyContent="space-between" flex={1}>
130+
<XStack gap="$s3" justifyContent="flex-start" alignItems="center">
131+
<Fingerprint color="$backgroundBrand" />
132+
<Text subHeadline color="$uiNeutralPrimary">
133+
{t("Signature webauthn")}
134+
</Text>
135+
</XStack>
136+
{(signWebauthnSuccess || signWebauthnError) && (
137+
<Check color={signWebauthnSuccess ? "$backgroundBrand" : "$interactiveBaseErrorDefault"} />
138+
)}
139+
</XStack>
140+
</XStack>
141+
</Pressable>
142+
<Separator borderColor="$borderNeutralSoft" />
71143
<Pressable
72144
onPress={() => {
73145
if (!connector) return;

src/i18n/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"Swap": "Intercambiar",
55
"Settings": "Configuración",
66
"Support": "Soporte",
7+
"Signature": "Firma",
8+
"Signature webauthn": "Firma webauthn",
79
"Logout": "Cerrar sesión",
810
"Copied": "Copiado",
911
"App version has been copied to the clipboard.": "La versión de la aplicación fue copiada al portapapeles.",

src/utils/server.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,23 @@ import { Platform } from "react-native";
22
import { get as assert, create } from "react-native-passkeys";
33

44
import { sdk } from "@farcaster/miniapp-sdk";
5+
import { bufferToBase64URLString } from "@simplewebauthn/browser";
56
import { getConnection, signMessage } from "@wagmi/core";
67
import { hc, parseResponse, type InferResponseType } from "hono/client";
78
import { check, number, object, parse, pipe, safeParse, string, ValiError } from "valibot";
8-
import { UserRejectedRequestError } from "viem";
99

10+
import { hashMessage, hexToBytes, UserRejectedRequestError } from "viem";
1011
import AUTH_EXPIRY from "@exactly/common/AUTH_EXPIRY";
1112
import deriveAddress from "@exactly/common/deriveAddress";
1213
import domain from "@exactly/common/domain";
13-
import { Credential } from "@exactly/common/validation";
1414

15+
import { Credential } from "@exactly/common/validation";
1516
import { login as loginIntercom, logout as logoutIntercom } from "./intercom";
1617
import { decrypt, decryptPIN, encryptPIN, session } from "./panda";
1718
import queryClient, { APIError, triage, type AuthMethod } from "./queryClient";
1819
import { classifyError } from "./reportError";
19-
import ownerConfig from "./wagmi/owner";
2020

21+
import ownerConfig from "./wagmi/owner";
2122
import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries
2223

2324
queryClient.setQueryDefaults<number | undefined>(["auth"], {
@@ -158,6 +159,37 @@ export async function setCardPIN(pin: string) {
158159
if (!response.ok) throw new APIError(response.status, stringOrLegacy(await response.json()));
159160
}
160161

162+
export async function signCard() {
163+
await auth();
164+
const address = getConnection(ownerConfig).address;
165+
if (!address) throw new Error("no owner wallet connected");
166+
const token = await api.card.$patch({ json: { method: "siwe", action: "challenge" } });
167+
if (!token.ok) throw new APIError(token.status, stringOrLegacy(await token.json()));
168+
const { challenge } = parse(object({ challenge: string() }), await token.json());
169+
const signature = await signMessage(ownerConfig, { account: address, message: challenge });
170+
const verify = await api.card.$patch({ json: { method: "siwe", action: "verify", message: challenge, signature } });
171+
if (!verify.ok) throw new APIError(verify.status, stringOrLegacy(await verify.json()));
172+
}
173+
174+
export async function signCardWebauthn() {
175+
await auth();
176+
const credential = await getCredential();
177+
const challengeResponse = await api.card.$patch({ json: { method: "webauthn", action: "challenge" } });
178+
if (!challengeResponse.ok) {
179+
throw new APIError(challengeResponse.status, stringOrLegacy(await challengeResponse.json()));
180+
}
181+
const { challenge } = parse(object({ challenge: string() }), await challengeResponse.json());
182+
183+
const assertion = await assert({
184+
challenge: bufferToBase64URLString(hexToBytes(hashMessage(challenge)).buffer as ArrayBuffer),
185+
rpId: domain,
186+
allowCredentials: Platform.OS === "android" ? undefined : [{ id: credential.credentialId, type: "public-key" }],
187+
});
188+
if (!assertion) throw new Error("bad assertion");
189+
const verify = await api.card.$patch({ json: { method: "webauthn", action: "verify", assertion } });
190+
if (!verify.ok) throw new APIError(verify.status, stringOrLegacy(await verify.json()));
191+
}
192+
161193
export async function getKYCTokens(scope: "basic" | "cardLimit" | "manteca" = "basic", redirectURI?: string) {
162194
await auth();
163195
const response = await api.kyc.$post({ json: { scope, redirectURI } });

0 commit comments

Comments
 (0)